Compare commits

..
7 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 6fccb6fe35 P7: downvotes go out, and feeds are read through the server's reader
Build / Build (push) Successful in 8m45s
Deploy / privapub.thepra.dev (push) Successful in 9m0s
Votes out. A persona's downvote is a Dislike (POST /api/v1/statuses/:id/downvote and /undownvote, the viewer's own as
privapub.votes.downvoted). One vote a post: a changed vote is sent as the new vote alone, as Lemmy sends one, since an
Undo of the old one beside it could arrive after it and take the new one away; Undo goes only when a vote is taken back.
A vote on a post in a remote community goes to the community, which counts it, and to the author only when on another
server: one copy a server, since PieFed drops a second copy of an activity it has just seen. Mbin keeps a favourite
apart from a vote, so there a favourite stays after a switch to a downvote.

Feeds followed (owner decision 2026-10-07: through the server). Following a feed (Lemmy's multi-community, PieFed's feed)
keeps a FeedSubscription for the persona and nothing else; the new Service privapub_feeds (LocalActorKind.Reader,
reserved by migration _017) follows every community of the feeds read here, reconciled when a persona follows or leaves
one, when a feed's list is read again (kept as it was when it cannot be read) and every six hours. Its Following rows
carry FollowerKind, so nobody's home gets what it brings in and its unanswered follows are sent again as its own. The
persona reads GET /api/v1/timelines/feed/:id (the feed's threads, ours included) and lists its feeds at GET /api/v1/feeds.

Checked in the pasture, every scenario: 873 pass. The 14 failures are Hubzilla's (identical on the previous commit:
Hubzilla no longer answers a follow in this pasture since its restore) and two activities Smithereen never sent;
followsync passes once the pasture's restore is older than the 14-day pause. Live: alice's downvotes count as downvotes
on Lemmy 1.0 and PieFed, replacing her upvote; Lemmy 1.0 and PieFed take privapub_feeds' follows and their threads reach
the feed timelines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-08 02:15:28 +02:00
thepraandClaude Opus 5.5 3b3f1f6b93 P7: feeds read as accounts, and PieFed's post Move
Build / Build (push) Successful in 7m50s
Lemmy 1.0's multi-communities and PieFed's feeds (`type: Feed`) resolve as accounts: the feed's `following` is kept
once a day with its counts, each community in it is read in turn, and `/api/v1/accounts/:id/following` lists them.
Lemmy's feed carries the instance's key and its text in `description`; both are taken as they are. Following a feed
answers 422 until the owner decides how a feed is followed.

A post moved to another community (PieFed's Move{object: post, origin, target}), relayed by the community it leaves,
goes there with its thread; a move into a community we host is not taken.

Checked in the pasture: a Lemmy 1.0 feed and a PieFed feed list their communities (ours among them); a PieFed post moved
from its web code arrives moved, with its comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-07 21:42:54 +02:00
thepraandClaude Opus 5.5 62189bf11d P7: thread backfill reads conversation containers and asks with If-None-Match
Build / Build (push) Failing after 7m52s
The FetchReplies job reads a thread's FEP-171b contextHistory before its FEP-7888 context: the posts each Add (or
Forte's plain Create) brought in, read from their own servers. A thread collection read whole keeps its ETag when the
document itself changes with every post (it counts them, or holds them all with no further page); the next read sends
it as If-None-Match and a 304 ends the job. The ETag is kept as sent, since NodeBB's has no quotes and the typed header
drops it. A context naming a post we hold (Forte's first post) is not fetched.

Checked in the pasture: Mastodon 4.7.3 answers the second read 304; NodeBB 4.16's unquoted ETag is kept (23/23 in its
scenario); a Forte thread completes through its replies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-07 21:33:49 +02:00
thepraandClaude Opus 5.5 fb4949b511 P7: FEP-268d searchableBy decides what search finds
Build / Build (push) Successful in 7m58s
A remote account's searchableBy, and a post's own, outrank indexable in status search: Public lets anyone find a
public post, the author's followers only those who follow it, anything else nobody but those it already reaches
(their own, named, favourited, bookmarked or boosted posts). Read on actors and posts, kept through edits; PrivaPub
does not emit it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-07 21:17:36 +02:00
thepraandClaude Opus 5.5 d4b1788a4f P7: community flairs, from Lemmy and PieFed
Build / Build (push) Successful in 8m2s
- A post's flairs are read in both dialects: Lemmy 1.0's CommunityPostTag (id, slug, name, description, colour slot)
  and PieFed's lemmy:CommunityTag (display name, text and background colours, whether to blur images). Colours are
  kept only as a colour slot or a hex value.
- A community's own list comes from its `tag` and PieFed's older `lemmy:tagsForPosts`, and fills in a post that names
  only a flair's id and slug, as Lemmy's posts do. Edits and refreshes keep them current.
- Clients get them as `privapub.flairs`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-07 21:14:32 +02:00
thepraandClaude Opus 5.5 587997dd67 P7: Lemmy's Warn and Resolve{Flag}
Build / Build (push) Successful in 8m37s
- A Warn from a community's moderator about a persona's own post there becomes that persona's moderation_warning
  notification (Mastodon's AccountWarning, with the reason and the post), believed from the community's own server or
  from an account the community's moderators collection lists. Remote communities keep that collection's address
  (attributedTo) as ForeignAvatar.ModeratorsURL; it is read only when a warning needs it.
- A Resolve{Flag} for one of our reports (`/grunts/flag-<report id>`) is kept as the report's remote resolution when it
  comes from the server holding what was reported, or the community it was reported to; our own moderators'
  resolution is never replaced. The moderators' report list shows both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-07 21:10:58 +02:00
thepraandClaude Opus 5.5 6fc2d001b2 ROADMAP: media, backups and archives deployed (v1.23.0, decePub v1.5.0)
Build / Build (push) Successful in 7m41s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 17:55:58 +02:00
61 changed files with 1683 additions and 104 deletions

No files matched your search

+9 -3
View File
@@ -22,8 +22,12 @@ Its defining idea: **one private login owns several public personas.**
- **`DmGroup` is a direct-message conversation.** - **`DmGroup` is a direct-message conversation.**
- **`privapub` is the instance actor** (type Application). It signs fetches no persona should be tied to. - **`privapub` is the instance actor** (type Application). It signs fetches no persona should be tied to.
- **`privapub_reports` is the reporter** (type Service, `LocalActorKind.Reporter`). It carries reports to Lemmy, which - **`privapub_reports` is the reporter** (type Service, `LocalActorKind.Reporter`). It carries reports to Lemmy, which
takes none from an Application, and to PieFed and Mbin, and names nobody. Both are server actors takes none from an Application, and to PieFed and Mbin, and names nobody.
(`LocalActor.IsServerActor`): never followed, mentioned or shown as accounts. - **`privapub_feeds` is the reader of feeds** (type Service, `LocalActorKind.Reader`). When a persona follows a feed
(Lemmy's multi-community, PieFed's feed), it follows the feed's communities in the server's name
(`Domain/Social/FeedFollows.cs`, owner decision 2026-10-07); its `Following` rows carry `FollowerKind`, and nobody's
home gets what it brings in. All three are server actors (`LocalActor.IsServerActor`): never followed, mentioned or
shown as accounts.
Privacy features in the model: Privacy features in the model:
- location-ranged posts (`Post.Location`, `RangeKm`), to become local-only and never federated; - location-ranged posts (`Post.Location`, `RangeKm`), to become local-only and never federated;
@@ -288,7 +292,9 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
(`NotificationPermission`). (`NotificationPermission`).
7. **What a Mastodon `Status` cannot say goes in `Status.privapub`** (`PrivaPubStatus`): object type, title, excerpt, 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, a remote post's own place (Pixelfed's `location`, cover, the author's source, link, video, audio and event details, a remote post's own place (Pixelfed's `location`,
never federated again), and up/down votes. Every media URL in it goes never federated again), up/down votes and the viewer's own downvote (`votes.downvoted`, set through PrivaPub's
`POST /api/v1/statuses/:id/downvote` and `/undownvote`; a favourite is the upvote), and its community's flairs
(`ObjectShapes.Flairs`, both dialects). 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 through the proxy; only page links (`link.url`, an event's online link) point at the remote site, because following
one is the reader's choice. one is the reader's choice.
+29 -8
View File
@@ -136,6 +136,9 @@ The names are the project's own and are stable; resolve actors through WebFinger
- The reporter is `/peasants/privapub_reports` (type `Service`, "Reports from <host>"), one for the whole server with - The reporter is `/peasants/privapub_reports` (type `Service`, "Reports from <host>"), one for the whole server with
its own key. It sends the reports Lemmy, PieFed and Mbin take in Lemmy's shape (see **Reports** below) and does its own key. It sends the reports Lemmy, PieFed and Mbin take in Lemmy's shape (see **Reports** below) and does
nothing else: nobody follows or mentions it, the Mastodon API has no account for it, and it has no page. nothing else: nobody follows or mentions it, the Mastodon API has no account for it, and it has no page.
- The reader of feeds is `/peasants/privapub_feeds` (type `Service`, "Feeds read on <host>"), one for the whole server
with its own key. When a persona here follows a feed, the reader follows each community in it, and stops following one
no feed read here holds any more; no community and no feed learns which persona reads it. It does nothing else.
## Groups ## Groups
@@ -167,7 +170,14 @@ A group is either a **community** or a **circle**.
and from a community elsewhere once the post's origin answers it gone. A lock sent as it is (`Lock`, Mbin) is taken and from a community elsewhere once the post's origin answers it gone. A lock sent as it is (`Lock`, Mbin) is taken
from the post's own server only. A lock (`Announce{Lock}`, or `commentsEnabled: false` on the post) refuses replies, from the post's own server only. A lock (`Announce{Lock}`, or `commentsEnabled: false` on the post) refuses replies,
ours included, until `Undo{Lock}`. A ban of a persona (`Announce{Block}` with the community as `target`) shows as ours included, until `Undo{Lock}`. A ban of a persona (`Announce{Block}` with the community as `target`) shows as
`blocked_by` on the community and refuses the persona's posts and replies there until the `Undo`. `blocked_by` on the community and refuses the persona's posts and replies there until the `Undo`. A post moved to
another community (PieFed's `Announce{Move{object: post, origin, target}}`, relayed by the community it leaves) goes
there with its thread; a move into a community this server hosts is not taken.
- **Feeds** (Lemmy 1.0's multi-communities and PieFed's feeds, `type: Feed`) are read as accounts whose following is
their communities: the feed's `following` collection, read once a day with its counts, and each community in it read in
turn. A persona following a feed is followed through the server (owner decision 2026-10-07): the reader of feeds
follows its communities, the feed itself is sent nothing, and the persona reads the feed's threads in a timeline of
its own (`GET /api/v1/timelines/feed/:id`; the feeds it reads are `GET /api/v1/feeds`), not in its home.
## Activities ## Activities
@@ -185,6 +195,8 @@ Received:
| `Like` | counted and notified, on posts the liker could see | | `Like` | counted and notified, on posts the liker could see |
| `Dislike` | counted as a downvote (Lemmy, PieFed, Mbin, Friendica); `Undo` takes it back | | `Dislike` | counted as a downvote (Lemmy, PieFed, Mbin, Friendica); `Undo` takes it back |
| `EmojiReact`, `Like` with an emoji `content` | an emoji reaction (FEP-c0e0; Pleroma, Akkoma, Iceshrimp.NET, Misskey, Sharkey), Unicode or a custom emoji from its `tag`; a `Like` whose content is ❤ stays a favourite; `Undo` takes it back | | `EmojiReact`, `Like` with an emoji `content` | an emoji reaction (FEP-c0e0; Pleroma, Akkoma, Iceshrimp.NET, Misskey, Sharkey), Unicode or a custom emoji from its `tag`; a `Like` whose content is ❤ stays a favourite; `Undo` takes it back |
| `Warn` (Lemmy 1.0) | a community moderator's warning about a persona's post there becomes the persona's `moderation_warning` notification, believed from the community's own server or an account its moderators collection lists |
| `Resolve{Flag}` (Lemmy 1.0) | a report we sent is marked resolved by the server holding what was reported (kept beside our own moderators' resolution) |
| `Join` | answered with `Ignore`: PrivaPub hosts no events of its own yet (FEP-8a8e) | | `Join` | answered with `Ignore`: PrivaPub hosts no events of its own yet (FEP-8a8e) |
| `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin | | `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin |
| `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back, though a new post its server publishes under that id after the deletion (Gancio numbers events from the last one kept) is kept | | `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back, though a new post its server publishes under that id after the deletion (Gancio numbers events from the last one kept) is kept |
@@ -194,8 +206,14 @@ Received:
| `Block` of a persona | the follows between them end, the blocker's posts and notifications are hidden from the persona, nothing of the persona's is addressed to the blocker, and the relationship says `blocked_by`; `Undo{Block}` lifts it | | `Block` of a persona | the follows between them end, the blocker's posts and notifications are hidden from the persona, nothing of the persona's is addressed to the blocker, and the relationship says `blocked_by`; `Undo{Block}` lifts it |
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll votes, `EmojiReact` and its `Undo`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`, Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll votes, `EmojiReact` and its `Undo`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
`Reject{Follow}`, `Like`, `Announce` and their `Undo`, `Flag`, `Join` and `Leave` of a remote event, and the replies to a `Reject{Follow}`, `Like`, `Dislike`, `Announce` and their `Undo`, `Flag`, `Join` and `Leave` of a remote event, and the
persona's posts passed on to its followers. A deleted post answers 410 with a `Tombstone`. replies to a persona's posts passed on to its followers. A deleted post answers 410 with a `Tombstone`.
- **Votes.** A favourite is a `Like`, a downvote a `Dislike`; a persona has one vote a post, and a changed vote is sent
as the new vote alone, as Lemmy sends one (an `Undo` of the old one beside it could arrive after it and take the new
one away). `Undo` is sent only when the vote is taken back. A vote on a post in a remote community goes to the
community, which counts it and passes it on, as Lemmy sends one, and to the post's author only when it lives on
another server: one copy a server, since PieFed drops a second copy of an activity it has just seen.
- **Attachments** are `Document`s with `mediaType`, `name` (alt text), `blurhash`, `focalPoint`, `width` and `height`. - **Attachments** are `Document`s with `mediaType`, `name` (alt text), `blurhash`, `focalPoint`, `width` and `height`.
Uploaded files have all metadata removed. Uploaded files have all metadata removed.
@@ -392,12 +410,15 @@ Posts with a location (shown to nearby users of this server) never leave the ser
- **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three - **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three
redirects and read at most 1 MB. redirects and read at most 1 MB.
- **Threads.** A reply's missing parents are fetched, up to 10 levels. When someone here opens a public remote thread, - **Threads.** A reply's missing parents are fetched, up to 10 levels. When someone here opens a public remote thread,
its replies are read from their servers, at most once an hour: the thread's `context` collection (FEP-7888) if it has its replies are read from their servers, at most once an hour: the thread's conversation container (FEP-171b's
one, else its `replies` (PeerTube's `comments`) and theirs, two levels down; 5 pages and 100 posts at most. Only `contextHistory`: the posts its owner's `Add`s brought in) or its `context` collection (FEP-7888) if it has one, else
public and unlisted replies are kept, each fetched from its own origin. its `replies` (PeerTube's `comments`) and theirs, two levels down; 5 pages and 100 posts at most. Only public and
unlisted replies are kept, each fetched from its own origin. A thread collection read whole, which counts its posts or
holds them all, is asked again with `If-None-Match` and its last ETag; a 304 ends the read.
- **Reading our documents (SecureMode).** privapub.thepra.dev answers ActivityPub GETs only when they are signed, like - **Reading our documents (SecureMode).** privapub.thepra.dev answers ActivityPub GETs only when they are signed, like
Mastodon's authorized fetch; the server's own actors (the instance actor `/peasants/privapub` and the reporter Mastodon's authorized fetch; the server's own actors (the instance actor `/peasants/privapub`, the reporter
`/peasants/privapub_reports`) are the exception, since their keys are needed first. `/peasants/privapub_reports` and the reader of feeds `/peasants/privapub_feeds`) are the exception, since their keys
are needed first.
A browser asking for HTML is redirected to the public page instead. A browser asking for HTML is redirected to the public page instead.
- **Posts that are not public** (followers-only, direct, circle) are served to a signed request from someone they were - **Posts that are not public** (followers-only, direct, circle) are served to a signed request from someone they were
for, or from the instance actor of a server where someone they were for lives, and to nobody else (404). Once deleted for, or from the instance actor of a server where someone they were for lives, and to nobody else (404). Once deleted
@@ -16,5 +16,7 @@ namespace PrivaPub.ClientModels.Admin
public bool IsResolved { get; set; } public bool IsResolved { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public DateTime? ResolvedAt { get; set; } public DateTime? ResolvedAt { get; set; }
public DateTime? RemoteResolvedAt { get; set; }//the server holding what was reported said it dealt with it
public string RemoteResolvedBy { get; set; }
} }
} }
@@ -71,6 +71,7 @@ namespace PrivaPub.Tests.Domain
public Task<LocalActor> FindByAddress(string address, CancellationToken token) => throw new NotSupportedException(); public Task<LocalActor> FindByAddress(string address, CancellationToken token) => throw new NotSupportedException();
public Task<LocalActor> GetInstanceActor(CancellationToken token) => throw new NotSupportedException(); public Task<LocalActor> GetInstanceActor(CancellationToken token) => throw new NotSupportedException();
public Task<LocalActor> GetReporterActor(CancellationToken token) => throw new NotSupportedException(); public Task<LocalActor> GetReporterActor(CancellationToken token) => throw new NotSupportedException();
public Task<LocalActor> GetReaderActor(CancellationToken token) => throw new NotSupportedException();
public Task<bool> IsUserNameTaken(string userName, CancellationToken token) => throw new NotSupportedException(); public Task<bool> IsUserNameTaken(string userName, CancellationToken token) => throw new NotSupportedException();
public Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token) => throw new NotSupportedException(); public Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token) => throw new NotSupportedException();
public LocalActor FromAvatar(Avatar avatar) => throw new NotSupportedException(); public LocalActor FromAvatar(Avatar avatar) => throw new NotSupportedException();
@@ -0,0 +1,115 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class CommunityModerationTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static string Origin(string id) => new Uri(id).GetLeftPart(UriPartial.Authority);
// a Lemmy community on the peer's first origin whose moderators collection lists the given accounts
RemoteActor Community(params RemoteActor[] moderators)
{
var community = new RemoteActor(_harness.Peer, "comm", type: "Group");
var document = community.Document();
document["attributedTo"] = community.Id + "/moderators";
_harness.Peer.Serve($"/users/{community.Name}", document.ToJsonString());
_harness.Peer.Serve($"/users/{community.Name}/moderators", new JsonObject
{
["id"] = community.Id + "/moderators", ["type"] = "OrderedCollection",
["orderedItems"] = new JsonArray(moderators.Select(m => (JsonNode)m.Id).ToArray())
}.ToJsonString());
return community;
}
async Task<(Post Post, PrivaPub.Federation.Actors.LocalActor Alice)> PostIn(RemoteActor community)
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "in the community" }, token);
var post = await DB.Default.Find<Post>().Match(p => p.GroupUserId == alice.Id).ExecuteFirstAsync(token);
await DB.Default.Update<Post>().MatchID(post.ID).Modify(p => p.AudienceURI, community.Id).ExecuteAsync(token);
await _harness.Remote.GetActor(community.Id, refresh: true, token);
return (post, alice);
}
JsonObject Warn(RemoteActor moderator, RemoteActor community, string persona, string post) => new()
{
["id"] = $"{Origin(moderator.Id)}/activities/warn/{Guid.NewGuid():N}", ["type"] = "Warn", ["actor"] = moderator.Id,
["to"] = new JsonArray(persona), ["object"] = post, ["summary"] = "please keep it civil", ["audience"] = community.Id
};
[Fact]
public async Task A_warning_from_the_communitys_moderators_reaches_the_persona_and_a_strangers_does_not()
{
var token = TestContext.Current.CancellationToken;
var listed = new RemoteActor(_harness.Peer, "listedmod", origin: _harness.Peer.B);
var stranger = new RemoteActor(_harness.Peer, "stranger", origin: _harness.Peer.B);
var community = Community(listed);
var admin = new RemoteActor(_harness.Peer, "admin");
var (post, alice) = await PostIn(community);
await _harness.Deliver(stranger, "/human-centipede", Warn(stranger, community, alice.Uri, post.ObjectURI));
Assert.False(await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id && n.Type == NotificationType.ModerationWarning).ExecuteAnyAsync(token));
await _harness.Deliver(listed, "/human-centipede", Warn(listed, community, alice.Uri, post.ObjectURI));
var warning = await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id && n.Type == NotificationType.ModerationWarning).ExecuteFirstAsync(token);
Assert.Equal("please keep it civil", warning.Text);
Assert.Equal(post.ID, warning.PostId);
var adminWarning = Warn(admin, community, alice.Uri, post.ObjectURI);
adminWarning["summary"] = "from the server's admin";
await DB.Default.DeleteAsync<Notification>(n => n.AvatarId == alice.Id);
await _harness.Deliver(admin, "/human-centipede", adminWarning);
Assert.Equal("from the server's admin", (await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id && n.Type == NotificationType.ModerationWarning).ExecuteFirstAsync(token)).Text);
}
[Fact]
public async Task A_reports_resolution_is_kept_only_from_the_server_holding_what_was_reported()
{
var token = TestContext.Current.CancellationToken;
var community = Community();
var (post, _) = await PostIn(community);
var report = new Report { PostIds = new() { post.ID }, Comment = "spam" };
await DB.Default.SaveAsync(report, token);
var flag = $"{_harness.Local.BaseAddress}/peasants/privapub_reports/grunts/flag-{report.ID}-1";
JsonObject Resolve(RemoteActor by) => new()
{
["id"] = $"{Origin(by.Id)}/activities/resolve/{Guid.NewGuid():N}", ["type"] = "Resolve", ["actor"] = by.Id, ["to"] = new JsonArray(community.Id),
["object"] = new JsonObject { ["id"] = flag, ["type"] = "Flag", ["actor"] = $"{_harness.Local.BaseAddress}/peasants/privapub_reports", ["object"] = post.ObjectURI }
};
var elsewhere = new RemoteActor(_harness.Peer, "elsewhere", origin: _harness.Peer.B);
var moderator = new RemoteActor(_harness.Peer, "moderator");
await _harness.Deliver(elsewhere, "/human-centipede", Resolve(elsewhere));
Assert.Null((await DB.Default.Find<Report>().OneAsync(report.ID, token)).RemoteResolvedAt);
await _harness.Deliver(moderator, "/human-centipede", Resolve(moderator));
var resolved = await DB.Default.Find<Report>().OneAsync(report.ID, token);
Assert.NotNull(resolved.RemoteResolvedAt);
Assert.Equal(moderator.Id, resolved.RemoteResolvedBy);
Assert.False(resolved.IsResolved);
}
}
}
+68
View File
@@ -0,0 +1,68 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
public class FlairTests
{
[Fact]
public void A_lemmy_posts_flair_is_described_by_its_community()
{
using var group = JsonDocument.Parse("""
{
"id": "https://lemmy.example/c/tenforward", "type": "Group", "preferredUsername": "tenforward", "inbox": "https://lemmy.example/c/tenforward/inbox",
"attributedTo": "https://lemmy.example/c/tenforward/moderators",
"tag": [{ "type": "CommunityPostTag", "id": "https://lemmy.example/c/tenforward/tag/news", "preferredUsername": "news",
"name": "News", "content": "What happened", "color": "color03" }]
}
""");
var community = ActorDocument.Parse(group.RootElement);
var page = NoteParser.Parse(JsonNode.Parse("""
{
"id": "https://lemmy.example/post/1", "type": "Page", "name": "Big news", "attributedTo": "https://lemmy.example/u/bo",
"tag": [{ "type": "CommunityPostTag", "id": "https://lemmy.example/c/tenforward/tag/news", "preferredUsername": "news" },
{ "type": "Mention", "href": "https://lemmy.example/c/tenforward", "name": "@tenforward@lemmy.example" }]
}
"""));
Assert.Equal("https://lemmy.example/c/tenforward/moderators", community.Moderators);
var flair = Assert.Single(ObjectShapes.Described(page.Flairs, community.Flairs));
Assert.Equal("News", flair.Name);
Assert.Equal("news", flair.Slug);
Assert.Equal("color03", flair.Color);
Assert.Equal("What happened", flair.Description);
}
[Fact]
public void Reads_piefeds_flairs_in_both_dialects_and_its_older_community_list()
{
var post = NoteParser.Parse(JsonNode.Parse("""
{
"id": "https://piefed.example/post/9", "type": "Page", "name": "A question", "attributedTo": "https://piefed.example/u/pf",
"tag": [{ "type": "lemmy:CommunityTag", "id": "https://piefed.example/c/ask/tag/3", "display_name": "Solved",
"text_color": "#ffffff", "background_color": "#2e7d32", "blur_images": false },
{ "type": "Hashtag", "href": "https://piefed.example/tag/q", "name": "#q" }]
}
"""));
using var group = JsonDocument.Parse("""
{
"id": "https://piefed.example/c/ask", "type": "Group", "preferredUsername": "ask", "inbox": "https://piefed.example/c/ask/inbox",
"lemmy:tagsForPosts": [{ "type": "lemmy:CommunityTag", "id": "https://piefed.example/c/ask/tag/4", "display_name": "Spoiler",
"text_color": "#000", "background_color": "red; background: url(x)", "blur_images": true }]
}
""");
var solved = Assert.Single(post.Flairs);
Assert.Equal("Solved", solved.Name);
Assert.Equal("#2e7d32", solved.BackgroundColor);
Assert.Equal("q", Assert.Single(post.Tags));
var spoiler = Assert.Single(ActorDocument.Parse(group.RootElement).Flairs);
Assert.True(spoiler.BlurImages);
Assert.Equal("#000", spoiler.TextColor);
Assert.Null(spoiler.BackgroundColor);
}
}
}
@@ -412,6 +412,48 @@ namespace PrivaPub.Tests.Federation
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(reply.ID, token)).FavouritesCount); Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(reply.ID, token)).FavouritesCount);
} }
// PieFed moves a post to another community, and the community it leaves relays the Move: the post's thread goes with it
[Fact]
public async Task A_post_moved_to_another_community_takes_its_thread_along()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var community = new RemoteActor(_harness.Peer, "cats", type: "Group");
var other = new RemoteActor(_harness.Peer, "dogs", type: "Group");
var poster = new RemoteActor(_harness.Peer, "poster");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token);
await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
async Task<Post> Announced(JsonObject note)
{
note["audience"] = community.Id;
_harness.Peer.Serve(new Uri(IdOf(note)).AbsolutePath, note.ToJsonString());
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, note)));
return await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token);
}
var page = PublicNote(poster, "<p>misplaced</p>", community.Id);
page["type"] = "Page";
page["name"] = "misplaced";
var post = await Announced(page);
var comment = PublicNote(poster, "<p>a comment</p>", community.Id);
comment["inReplyTo"] = IdOf(page);
var answer = await Announced(comment);
var move = new JsonObject
{
["id"] = NewId(poster, "moves"), ["type"] = "Move", ["actor"] = poster.Id, ["object"] = post.ObjectURI, ["origin"] = community.Id, ["target"] = other.Id
};
// only the community it leaves speaks for it
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", new JsonObject
{
["id"] = NewId(poster, "moves"), ["type"] = "Move", ["actor"] = poster.Id, ["object"] = post.ObjectURI, ["origin"] = other.Id, ["target"] = community.Id
}));
Assert.Equal(("dropped", "unsupported"), (Processed("Announce").Outcome, Processed("Announce").Reason));
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", move));
Assert.Equal(("accepted", "moved"), (Processed("Announce").Outcome, Processed("Announce").Reason));
Assert.Equal(other.Id, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).AudienceURI);
Assert.Equal(other.Id, (await DB.Default.Find<Post>().OneAsync(answer.ID, token)).AudienceURI);
}
// Lemmy's moderators lock a community's post and ban members, and the community relays both inside its Announce // Lemmy's moderators lock a community's post and ban members, and the community relays both inside its Announce
// (G-0006): a locked post takes no reply, a banned persona posts nothing there, until the Undo // (G-0006): a locked post takes no reply, a banned persona posts nothing there, until the Undo
[Fact] [Fact]
@@ -216,6 +216,103 @@ namespace PrivaPub.Tests.Federation
Assert.All(_harness.Peer.Requests.Where(r => r.Path.EndsWith("/replies")), r => Assert.False(string.IsNullOrEmpty(r.Signature))); Assert.All(_harness.Peer.Requests.Where(r => r.Path.EndsWith("/replies")), r => Assert.False(string.IsNullOrEmpty(r.Signature)));
} }
[Fact]
public async Task A_conversation_container_is_read_before_the_context_and_only_its_added_posts_taken()
{
var owner = new RemoteActor(_harness.Peer, "owner");
var answerer = new RemoteActor(_harness.Peer, "answerer");
var root = PublicNote(owner, "<p>a conversation</p>");
var history = NewId(owner, "conversations");
root["contextHistory"] = history;
root["context"] = NewId(owner, "never");
Served(root);
var embedded = Served(Reply(answerer, "added whole", root));
var referenced = Served(Reply(owner, "added by reference", embedded));
var liked = Served(Reply(answerer, "only liked", root));
var target = new JsonObject { ["type"] = "OrderedCollection", ["id"] = history, ["attributedTo"] = owner.Id };
JsonObject Add(JsonNode activity) => new()
{
["id"] = NewId(owner, "activities"), ["type"] = "Add", ["actor"] = owner.Id, ["object"] = activity, ["target"] = target.DeepClone()
};
var create = Served(Create(owner, referenced));
create["object"] = IdOf(referenced);
Served(create);
var byReference = Served(Add(IdOf(create)));
Served(new JsonObject
{
["id"] = history, ["type"] = "OrderedCollection", ["attributedTo"] = owner.Id, ["collectionOf"] = "Activity",
["orderedItems"] = new JsonArray(Create(owner, root), Add(Create(answerer, embedded)), IdOf(byReference), Add(Activity(answerer, "Like", JsonValue.Create(IdOf(liked)))))
});
var held = await Held(root);
Assert.Equal(1, await RunReplies(held));
var stored = await Stored(embedded, referenced, liked);
Assert.Equal(new[] { IdOf(embedded), IdOf(referenced) }.Order(), stored.Keys.Order());
Assert.Equal(held.ID, stored[IdOf(embedded)].AnsweringToPostId);
Assert.Equal(stored[IdOf(embedded)].ID, stored[IdOf(referenced)].AnsweringToPostId);
Assert.DoesNotContain(_harness.Peer.Requests, r => r.Path == new Uri(root["context"]!.GetValue<string>()).AbsolutePath);
}
[Fact]
public async Task An_unchanged_context_is_not_read_again_while_it_counts_the_thread()
{
var token = TestContext.Current.CancellationToken;
var poster = new RemoteActor(_harness.Peer, "poster");
var answerer = new RemoteActor(_harness.Peer, "answerer");
var root = PublicNote(poster, "<p>a topic</p>");
var context = NewId(poster, "topics");
var path = new Uri(context).AbsolutePath;
root["context"] = context;
Served(root);
var first = Served(Reply(answerer, "first", root));
var later = Served(Reply(poster, "later", root));
// NodeBB's: every post inline, counted, and an ETag without quotes
JsonObject Thread(params JsonObject[] notes) => new()
{
["id"] = context, ["type"] = "OrderedCollection", ["totalItems"] = notes.Length,
["orderedItems"] = new JsonArray(notes.Select(n => (JsonNode)IdOf(n)).ToArray())
};
_harness.Peer.ServeTagged(path, Thread(root, first).ToJsonString(), "4f930186eb");
var held = await Held(root);
var handler = new RepliesJobHandler(_harness.Db, _harness.Remote, _harness.RemotePosts, _harness.Queue);
await handler.Handle(RepliesJobHandler.For(held, 1), token);
Assert.Equal("4f930186eb", (await DB.Default.Find<ThreadCollection>().Match(c => c.URI == context).ExecuteFirstAsync(token)).ETag);
// the same version: a 304, and nothing read
_harness.Peer.ServeTagged(path, Thread(root, first, later).ToJsonString(), "4f930186eb");
await handler.Handle(RepliesJobHandler.For(held, 1), token);
Assert.Equal("4f930186eb", _harness.Peer.Requests.Last(r => r.Path == path).Headers["If-None-Match"]);
Assert.Single(await Stored(first, later));
// a new one
_harness.Peer.ServeTagged(path, Thread(root, first, later).ToJsonString(), "5a0b");
await handler.Handle(RepliesJobHandler.For(held, 1), token);
Assert.Equal(2, (await Stored(first, later)).Count);
Assert.Equal("5a0b", (await DB.Default.Find<ThreadCollection>().Match(c => c.URI == context).ExecuteFirstAsync(token)).ETag);
}
[Fact]
public async Task A_paged_contexts_etag_is_not_kept_since_its_first_page_hides_what_is_added_later()
{
var token = TestContext.Current.CancellationToken;
var poster = new RemoteActor(_harness.Peer, "poster");
var root = PublicNote(poster, "<p>a long thread</p>");
var context = NewId(poster, "contexts");
root["context"] = context;
Served(root);
var answer = Served(Reply(poster, "on the second page", root));
_harness.Peer.ServeTagged(new Uri(context).AbsolutePath, new JsonObject
{
["id"] = context, ["type"] = "Collection",
["first"] = new JsonObject { ["type"] = "CollectionPage", ["items"] = new JsonArray(IdOf(root)), ["next"] = context + "/2" }
}.ToJsonString(), "W/\"first\"");
Served(new JsonObject { ["id"] = context + "/2", ["type"] = "CollectionPage", ["items"] = new JsonArray(IdOf(answer)) });
var held = await Held(root);
await new RepliesJobHandler(_harness.Db, _harness.Remote, _harness.RemotePosts, _harness.Queue).Handle(RepliesJobHandler.For(held, 1), token);
Assert.Single(await Stored(answer));
Assert.False(await DB.Default.Find<ThreadCollection>().Match(c => c.URI == context && c.ETag != null).ExecuteAnyAsync(token));
}
[Fact] [Fact]
public async Task A_threads_context_collection_brings_every_reply_at_once() public async Task A_threads_context_collection_brings_every_reply_at_once()
{ {
+140
View File
@@ -0,0 +1,140 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
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;
namespace PrivaPub.Tests.Http
{
// Lemmy 1.0's multi-communities and PieFed's feeds are `Feed` actors whose `following` is their communities
[Trait("Category", "Integration")]
public sealed class MastodonFeedTests : IAsyncLifetime
{
PrivaPubHost _host;
Peer _peer;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
_peer = await Peer.Start();
}
public async ValueTask DisposeAsync()
{
if (_peer != default)
await _peer.DisposeAsync();
}
[Fact]
public async Task A_feed_shows_as_an_account_whose_following_is_its_communities()
{
var token = TestContext.Current.CancellationToken;
var reader = await _host.Mastodon("feeder");
var cats = new RemoteActor(_peer, "cats", type: "Group");
var dogs = new RemoteActor(_peer, "dogs", type: "Group");
var name = $"feed{Guid.NewGuid():N}"[..12];
var feed = $"{_peer.A}/m/{name}";
// as Lemmy serves one: the instance's inbox and key, its text in `description`
_peer.Serve($"/m/{name}", new JsonObject
{
["@context"] = new JsonArray("https://join-lemmy.org/context.json", "https://www.w3.org/ns/activitystreams"),
["type"] = "Feed", ["id"] = feed, ["inbox"] = $"{_peer.A}/inbox",
["publicKey"] = new JsonObject { ["id"] = $"{_peer.A}/#main-key", ["owner"] = $"{_peer.A}/", ["publicKeyPem"] = "-----BEGIN PUBLIC KEY-----\n-----END PUBLIC KEY-----\n" },
["following"] = $"{feed}/following", ["preferredUsername"] = name, ["name"] = "Pets", ["description"] = "every pet",
["attributedTo"] = $"{_peer.A}/users/owner"
}.ToJsonString());
_peer.Serve($"/m/{name}/following", new JsonObject
{
["type"] = "Collection", ["id"] = $"{feed}/following", ["totalItems"] = 2, ["items"] = new JsonArray(cats.Id, dogs.Id)
}.ToJsonString());
var account = await _host.Get<IRemoteActorService>().GetActor(feed, refresh: true, token);
Assert.NotNull(account);
await _host.Run(j => j.Kind == JobKind.CountAccount && j.Payload == feed, token);
var shown = (await reader.Client.Get($"/api/v1/accounts/{account.ID}")).Ok().Body;
Assert.Equal(("Pets", 2), (shown.Text("display_name"), shown.Number("following_count")));
Assert.Contains("every pet", shown.Text("note"));
var communities = (await reader.Client.Get($"/api/v1/accounts/{account.ID}/following")).Ok().Array;
Assert.Equal(new[] { cats.Id, dogs.Id }, communities.Select(c => c.Text("uri")));
Assert.All(communities, c => Assert.True(c!["group"]!.GetValue<bool>()));
}
// owner decision 2026-10-07: a feed is read through the server, whose reader follows its communities; the persona
// follows nothing, and reads the feed in a timeline of its own
[Fact]
public async Task A_feed_is_followed_by_the_servers_reader_and_read_in_its_own_timeline()
{
var token = TestContext.Current.CancellationToken;
var since = DateTime.UtcNow.AddSeconds(-1);
var persona = await _host.Mastodon("feedreader");
var cats = new RemoteActor(_peer, "cats", type: "Group");
var dogs = new RemoteActor(_peer, "dogs", type: "Group");
var poster = new RemoteActor(_peer, "poster");
var name = $"feed{Guid.NewGuid():N}"[..12];
var feed = $"{_peer.A}/m/{name}";
_peer.Serve($"/m/{name}", new JsonObject
{
["type"] = "Feed", ["id"] = feed, ["inbox"] = $"{_peer.A}/inbox", ["following"] = $"{feed}/following", ["preferredUsername"] = name, ["name"] = "Pets"
}.ToJsonString());
_peer.Serve($"/m/{name}/following", new JsonObject
{
["type"] = "Collection", ["id"] = $"{feed}/following", ["totalItems"] = 2, ["items"] = new JsonArray(cats.Id, dogs.Id)
}.ToJsonString());
var account = await _host.Get<IRemoteActorService>().GetActor(feed, refresh: true, token);
await _host.Run(j => j.Kind == JobKind.CountAccount && j.Payload == feed, token);
var followed = (await persona.Client.Post($"/api/v1/accounts/{account.ID}/follow")).Ok().Body;
Assert.True(followed.Flag("following"));
Assert.Equal(new[] { account.ID }, (await persona.Client.Get("/api/v1/feeds")).Ok().Ids);
await _host.Run(j => j.Kind == JobKind.SyncFeeds, token);
var readerActor = await _host.Get<ILocalActorService>().GetReaderActor(token);
var follows = await DB.Default.Find<Following>().Match(f => f.AvatarId == readerActor.Id).ExecuteAsync(token);
Assert.Equal(new[] { cats.Id, dogs.Id }.Order(), follows.Select(f => f.TargetActorURI).Order());
Assert.All(follows, f => Assert.Equal(LocalActorKind.Reader, f.FollowerKind));
Assert.False(await DB.Default.Find<Following>().Match(f => f.AvatarId == persona.Id).ExecuteAnyAsync(token));
var asked = Assert.Single(await cats.Delivered(since), d => d.Type() == "Follow");
Assert.Equal(readerActor.Uri, asked.Text("actor"));
var document = (await _host.Client().Fetch(new Uri(readerActor.Uri).AbsolutePath)).Json;
Assert.Equal(("Service", LocalActorService.ReaderUserName), (document.Text("type"), document.Text("preferredUsername")));
await _host.Deliver(cats, "/human-centipede", new JsonObject
{
["id"] = $"{cats.Id}/accepts/{Guid.NewGuid():N}", ["type"] = "Accept", ["actor"] = cats.Id, ["object"] = asked.Text("id")
});
var create = poster.Create("<p>a thread among cats</p>", new[] { "https://www.w3.org/ns/activitystreams#Public", cats.Id }, shape: note =>
{
note["type"] = "Page";
note["name"] = "Cats";
note["audience"] = cats.Id;
});
_peer.Serve(new Uri(create["object"]!.Text("id")).AbsolutePath, create["object"]!.ToJsonString());
await _host.Deliver(cats, "/human-centipede", new JsonObject
{
["id"] = $"{cats.Id}/announces/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = cats.Id, ["object"] = create,
["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public")
});
var thread = await DB.Default.Find<Post>().Match(p => p.ObjectURI == create["object"]!.Text("id")).ExecuteSingleAsync(token);
Assert.Equal(new[] { thread.ID }, (await persona.Client.Get($"/api/v1/timelines/feed/{account.ID}")).Ok().Ids);
Assert.DoesNotContain(thread.ID, (await persona.Client.Get("/api/v1/timelines/home")).Ok().Ids);
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.PostId == thread.ID).ExecuteAnyAsync(token));
var stranger = await _host.Mastodon("notreading");
Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Get($"/api/v1/timelines/feed/{account.ID}")).Status);
Assert.False((await persona.Client.Post($"/api/v1/accounts/{account.ID}/unfollow")).Ok().Body.Flag("following"));
await _host.Run(j => j.Kind == JobKind.SyncFeeds, token);
Assert.False(await DB.Default.Find<Following>().Match(f => f.AvatarId == readerActor.Id).ExecuteAnyAsync(token));
Assert.Contains(await cats.Delivered(since), d => d.Type() == "Undo" && d["object"].Text("type") == "Follow");
}
}
}
@@ -388,6 +388,47 @@ namespace PrivaPub.Tests.Http
Assert.True((await alice.Client.Post($"/api/v1/statuses/{quiet.Text("id")}/favourite")).Ok().Body.Flag("favourited")); Assert.True((await alice.Client.Post($"/api/v1/statuses/{quiet.Text("id")}/favourite")).Ok().Body.Flag("favourited"));
} }
// a downvote is a Dislike, one vote a post: a downvote replaces the favourite and a favourite the downvote, each by its
// own activity alone; a vote on a post in a community goes to the community, which counts it, and to its author only
// when on another server
[Fact]
public async Task A_downvote_replaces_the_favourite_and_votes_reach_the_posts_community()
{
var since = DateTime.UtcNow.AddSeconds(-1);
var alice = await _host.Mastodon("alice");
var bob = new RemoteActor(_peer, "bob", origin: _peer.B);
var community = new RemoteActor(_peer, "pies", type: "Group");
await _host.Get<PrivaPub.Federation.Actors.IRemoteActorService>().GetActor(community.Id, refresh: true, TestContext.Current.CancellationToken);
var remote = await _host.PublicPostFrom(bob, alice, shape: note => note["audience"] = community.Id);
var neighbour = new RemoteActor(_peer, "neighbour");
var nearby = await _host.PublicPostFrom(neighbour, alice, shape: note => note["audience"] = community.Id);
(await alice.Client.Post($"/api/v1/statuses/{nearby.ID}/favourite")).Ok();
Assert.Empty(await neighbour.Delivered(since));
var path = $"/api/v1/statuses/{remote.ID}";
(await alice.Client.Post(path + "/favourite")).Ok();
var down = (await alice.Client.Post(path + "/downvote")).Ok().Body;
Assert.False(down.Flag("favourited"));
Assert.Equal((0, 1, true), (down["privapub"]!["votes"].Number("up"), down["privapub"]!["votes"].Number("down"), down["privapub"]!["votes"].Flag("downvoted")));
Assert.Equal(1, (await alice.Client.Post(path + "/downvote")).Ok().Body["privapub"]!["votes"].Number("down"));
var up = (await alice.Client.Post(path + "/favourite")).Ok().Body;
Assert.Equal((true, 0, false), (up.Flag("favourited"), up["privapub"]!["votes"].Number("down"), up["privapub"]!["votes"].Flag("downvoted")));
Assert.Null((await _host.Client().Get(path)).Ok().Body["privapub"]!["votes"]!["downvoted"]);
(await alice.Client.Post(path + "/downvote")).Ok();
var none = (await alice.Client.Post(path + "/undownvote")).Ok().Body;
Assert.Equal((false, 0, 0), (none.Flag("favourited"), none.Number("favourites_count"), none["privapub"]!["votes"].Number("down")));
foreach (var inbox in new[] { bob, community })
{
var sent = (await inbox.Delivered(since)).Where(d => (d.Type() == "Undo" ? d["object"].Text("object") : d.Text("object")) == remote.ObjectURI).ToList();
Assert.Equal(new[] { "Like", "Dislike", "Like", "Dislike", "Undo:Dislike" },
sent.Select(d => d.Type() == "Undo" ? "Undo:" + d["object"].Text("type") : d.Type()));
var dislike = sent.Last(d => d.Type() == "Dislike");
Assert.Equal((alice.Uri, remote.ObjectURI), (dislike.Text("actor"), dislike.Text("object")));
Assert.Equal(dislike.Text("id"), sent.Single(d => d.Type() == "Undo")["object"].Text("id"));
}
}
[Fact] [Fact]
public async Task A_favourite_or_a_boost_given_again_after_its_undo_is_a_new_activity_that_is_delivered() public async Task A_favourite_or_a_boost_given_again_after_its_undo_is_a_new_activity_that_is_delivered()
{ {
@@ -1,6 +1,12 @@
using MongoDB.Entities;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support; using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host; using PrivaPub.Tests.Support.Host;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Http namespace PrivaPub.Tests.Http
{ {
// Status search by words, as Mastodon scopes it: the persona's own posts and those it boosted, favourited, bookmarked // Status search by words, as Mastodon scopes it: the persona's own posts and those it boosted, favourited, bookmarked
@@ -47,6 +53,67 @@ namespace PrivaPub.Tests.Http
(await reader.Client.Get($"/api/v2/search?q={word}&type=statuses&limit=1&offset=1")).Ok().Body["statuses"]!.AsArray().Select(s => s.Text("id"))); (await reader.Client.Get($"/api/v2/search?q={word}&type=statuses&limit=1&offset=1")).Ok().Body["statuses"]!.AsArray().Select(s => s.Text("id")));
} }
// FEP-268d: a remote account's searchableBy outranks its indexable, and a post's own outranks its author's
[Fact]
public async Task A_remote_searchable_by_decides_before_indexable()
{
var token = TestContext.Current.CancellationToken;
await using var peer = await Peer.Start();
var reader = await _host.Mastodon("reader");
var keeper = await _host.Mastodon("keeper");
var word = $"s{Guid.NewGuid():N}"[..12];
async Task<RemoteActor> Author(string name, bool indexable, Func<RemoteActor, JsonArray> searchableBy)
{
var author = new RemoteActor(peer, name);
var document = author.Document();
document["indexable"] = indexable;
if (searchableBy != default)
document["searchableBy"] = searchableBy(author);
peer.Serve($"/users/{author.Name}", document.ToJsonString());
await DB.Default.SaveAsync(new Following { AvatarId = keeper.Id, TargetActorURI = author.Id, State = FollowState.Accepted }, token);
return author;
}
async Task<string> Says(RemoteActor author, string text, JsonArray searchableBy = default)
{
var noteId = $"{peer.A}/notes/{Guid.NewGuid():N}";
var note = new JsonObject
{
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"<p>{text}</p>",
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(author.Id + "/followers"), ["published"] = DateTime.UtcNow.ToString("O")
};
if (searchableBy != default)
note["searchableBy"] = searchableBy;
using var client = _host.Client();
await client.SendAsync(author.SignedPost("/human-centipede", new JsonObject
{
["id"] = noteId + "/activity", ["type"] = "Create", ["actor"] = author.Id,
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(author.Id + "/followers"), ["object"] = note
}), token);
Assert.Equal(1, await _host.RunInbox(noteId + "/activity", token));
return (await DB.Default.Find<Models.Post.Post>().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(token)).ID;
}
var open = await Says(await Author("open", indexable: false, _ => new JsonArray(Addressing.Public)), $"open {word}");
var closed = await Says(await Author("closed", indexable: true, _ => new JsonArray()), $"closed {word}");
var friendly = await Author("friendly", indexable: false, a => new JsonArray(a.Id + "/followers"));
var friends = await Says(friendly, $"friends {word}");
var quiet = await Author("quiet", indexable: false, default);
var perPost = await Says(quiet, $"per post {word}", new JsonArray(Addressing.Public));
var plain = await Says(quiet, $"plain {word}");
static List<string> Found(JsonNode body) => body["statuses"]!.AsArray().Select(s => s.Text("id")).ToList();
var before = Found((await reader.Client.Get($"/api/v2/search?q={word}&type=statuses&limit=40")).Ok().Body);
Assert.Contains(open, before);
Assert.Contains(perPost, before);
Assert.DoesNotContain(closed, before);
Assert.DoesNotContain(friends, before);
Assert.DoesNotContain(plain, before);
await DB.Default.SaveAsync(new Following { AvatarId = reader.Id, TargetActorURI = friendly.Id, State = FollowState.Accepted }, token);
Assert.Contains(friends, Found((await reader.Client.Get($"/api/v2/search?q={word}&type=statuses&limit=40")).Ok().Body));
}
// a handle asks for its account: no posts sharing its words, and no hashtag made of it // a handle asks for its account: no posts sharing its words, and no hashtag made of it
[Fact] [Fact]
public async Task A_handle_finds_its_account_and_neither_posts_nor_a_hashtag() public async Task A_handle_finds_its_account_and_neither_posts_nor_a_hashtag()
+2
View File
@@ -71,6 +71,8 @@ namespace PrivaPub.Tests.Support
new LikeHandler(Db, Reactions), new LikeHandler(Db, Reactions),
new EmojiReactHandler(Db, Reactions), new EmojiReactHandler(Db, Reactions),
new QuoteRequestHandler(Quotes), new QuoteRequestHandler(Quotes),
new WarnHandler(Db, Local, Remote),
new ResolveHandler(Db, Local),
new DislikeHandler(Db), new DislikeHandler(Db),
new JoinHandler(Db, Local, Delivery), new JoinHandler(Db, Local, Delivery),
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote, Records, Quotes, relays: Relays), new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote, Records, Quotes, relays: Relays),
+17
View File
@@ -21,6 +21,7 @@ namespace PrivaPub.Tests.Support
readonly ConcurrentDictionary<string, (string Text, string ContentType)> _documents = new(); readonly ConcurrentDictionary<string, (string Text, string ContentType)> _documents = new();
readonly ConcurrentDictionary<string, (int Status, TimeSpan Delay)> _answers = new(); readonly ConcurrentDictionary<string, (int Status, TimeSpan Delay)> _answers = new();
readonly ConcurrentDictionary<string, (byte[] Bytes, string ContentType)> _files = new(); readonly ConcurrentDictionary<string, (byte[] Bytes, string ContentType)> _files = new();
readonly ConcurrentDictionary<string, string> _etags = new();
public int Port { get; } public int Port { get; }
public string A => $"http://127.0.0.1:{Port}"; public string A => $"http://127.0.0.1:{Port}";
@@ -64,6 +65,15 @@ namespace PrivaPub.Tests.Support
context.Response.StatusCode = StatusCodes.Status404NotFound; context.Response.StatusCode = StatusCodes.Status404NotFound;
return; return;
} }
if (peer._etags.TryGetValue(key, out var etag))
{
if (context.Request.Headers.IfNoneMatch.ToString() == etag)
{
context.Response.StatusCode = StatusCodes.Status304NotModified;
return;
}
context.Response.Headers.ETag = etag;
}
context.Response.ContentType = document.ContentType; context.Response.ContentType = document.ContentType;
await context.Response.WriteAsync(document.Text.Replace("{A}", peer.A).Replace("{B}", peer.B)); await context.Response.WriteAsync(document.Text.Replace("{A}", peer.A).Replace("{B}", peer.B));
}); });
@@ -74,6 +84,13 @@ namespace PrivaPub.Tests.Support
public void Serve(string path, string json) => _documents[path] = (json, "application/activity+json"); public void Serve(string path, string json) => _documents[path] = (json, "application/activity+json");
// a document with an ETag: answered 304 to a request that names it in If-None-Match
public void ServeTagged(string path, string json, string etag)
{
_documents[path] = (json, "application/activity+json");
_etags[path] = etag;
}
public void ServeText(string path, string text, string contentType) => _documents[path] = (text, contentType); public void ServeText(string path, string text, string contentType) => _documents[path] = (text, contentType);
public void ServeFile(string path, byte[] bytes, string contentType) => _files[path] = (bytes, contentType); public void ServeFile(string path, byte[] bytes, string contentType) => _files[path] = (bytes, contentType);
@@ -32,10 +32,12 @@ namespace PrivaPub.Api.Mastodon.Controllers
readonly IFollowService _follows; readonly IFollowService _follows;
readonly IOutboxPublisher _outbox; readonly IOutboxPublisher _outbox;
readonly IRelationshipService _relationships; readonly IRelationshipService _relationships;
readonly IFeedFollows _feeds;
public AccountsController(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, public AccountsController(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors,
IFollowService follows, IOutboxPublisher outbox, IRelationshipService relationships) IFollowService follows, IOutboxPublisher outbox, IRelationshipService relationships, IFeedFollows feeds)
{ {
_feeds = feeds;
_relationships = relationships; _relationships = relationships;
_mapper = mapper; _mapper = mapper;
_dbEntities = dbEntities; _dbEntities = dbEntities;
@@ -253,7 +255,10 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/accounts/{id}/following"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] [HttpGet("/api/v1/accounts/{id}/following"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Following(string id, CancellationToken token) public async Task<IActionResult> Following(string id, CancellationToken token)
{ {
var (local, _) = await Find(id, token); var (local, remote) = await Find(id, token);
// a feed's following is what it is: its communities, as its server lists them
if (remote is { AvatarType: AvatarType.Feed })
return Json(await AccountsFor(remote.FeedCommunities.Take(Limit(40, 80)), token));
if (local == default || local.Id != MyId) if (local == default || local.Id != MyId)
return Json(Array.Empty<Account>()); return Json(Array.Empty<Account>());
var following = await Page.From(Params, Limit(40, 80)) var following = await Page.From(Params, Limit(40, 80))
@@ -263,12 +268,27 @@ namespace PrivaPub.Api.Mastodon.Controllers
return Json(following.Select(f => accounts.GetValueOrDefault(f.TargetAccountId)).Where(a => a != default).ToList()); return Json(following.Select(f => accounts.GetValueOrDefault(f.TargetAccountId)).Where(a => a != default).ToList());
} }
// PrivaPub's own: the feeds the persona reads (a feed's timeline is /api/v1/timelines/feed/:id)
[HttpGet("/api/v1/feeds"), Scope("read:follows")]
public async Task<IActionResult> Feeds(CancellationToken token)
{
var read = await DB.Default.Find<FeedSubscription>().Match(s => s.AvatarId == MyId).Sort(s => s.ID, MongoDB.Entities.Order.Descending).ExecuteAsync(token);
var accounts = await _mapper.Accounts(read.Select(s => s.FeedAccountId), token);
return Json(read.Select(s => accounts.GetValueOrDefault(s.FeedAccountId)).Where(a => a != default).ToList());
}
[HttpPost("/api/v1/accounts/{id}/follow"), Scope("write:follows")] [HttpPost("/api/v1/accounts/{id}/follow"), Scope("write:follows")]
public async Task<IActionResult> Follow(string id, CancellationToken token) public async Task<IActionResult> Follow(string id, CancellationToken token)
{ {
var (local, remote) = await Find(id, token); var (local, remote) = await Find(id, token);
if (local == default && remote == default) if (local == default && remote == default)
return NotFoundError(); return NotFoundError();
// a feed is read through the server, whose reader follows its communities; the persona follows nothing
if (remote is { AvatarType: AvatarType.Feed })
{
await _feeds.Subscribe(Me, remote, true, token);
return Json(await Relationship(id, token));
}
var following = await _follows.FollowAs(Me, local?.Uri ?? remote.ActorURI, Params.Bool("reblogs") != false, token); var following = await _follows.FollowAs(Me, local?.Uri ?? remote.ActorURI, Params.Bool("reblogs") != false, token);
return following == default ? Error(StatusCodes.Status403Forbidden, "This action is not allowed") : Json(await Relationship(id, token)); return following == default ? Error(StatusCodes.Status403Forbidden, "This action is not allowed") : Json(await Relationship(id, token));
} }
@@ -279,7 +299,10 @@ namespace PrivaPub.Api.Mastodon.Controllers
var (local, remote) = await Find(id, token); var (local, remote) = await Find(id, token);
if (local == default && remote == default) if (local == default && remote == default)
return NotFoundError(); return NotFoundError();
await _follows.UnfollowAs(Me, local?.Uri ?? remote.ActorURI, token); if (remote is { AvatarType: AvatarType.Feed })
await _feeds.Subscribe(Me, remote, false, token);
else
await _follows.UnfollowAs(Me, local?.Uri ?? remote.ActorURI, token);
return Json(await Relationship(id, token)); return Json(await Relationship(id, token));
} }
@@ -427,6 +450,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
var (local, remote) = await Find(id, token); var (local, remote) = await Find(id, token);
var uri = local?.Uri ?? remote?.ActorURI; var uri = local?.Uri ?? remote?.ActorURI;
var following = uri == default ? default : await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetActorURI == uri).ExecuteFirstAsync(token); var following = uri == default ? default : await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetActorURI == uri).ExecuteFirstAsync(token);
var reading = remote is { AvatarType: AvatarType.Feed } && await _feeds.IsSubscribed(MyId, remote.ActorURI, token);
var followedBy = uri == default ? default : await _dbEntities.Followers.Match(f => f.LocalActorId == MyId && f.ActorURI == uri).ExecuteFirstAsync(token); var followedBy = uri == default ? default : await _dbEntities.Followers.Match(f => f.LocalActorId == MyId && f.ActorURI == uri).ExecuteFirstAsync(token);
var blocking = uri != default && await DB.Default.Find<Block>().Match(b => b.AvatarId == MyId && b.TargetActorURI == uri).ExecuteAnyAsync(token); var blocking = uri != default && await DB.Default.Find<Block>().Match(b => b.AvatarId == MyId && b.TargetActorURI == uri).ExecuteAnyAsync(token);
var mute = uri == default ? default : await DB.Default.Find<Mute>().Match(m => m.AvatarId == MyId && m.TargetActorURI == uri).ExecuteFirstAsync(token); var mute = uri == default ? default : await DB.Default.Find<Mute>().Match(m => m.AvatarId == MyId && m.TargetActorURI == uri).ExecuteFirstAsync(token);
@@ -443,7 +467,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
Muting = mute != default && (mute.ExpiresAt == default || mute.ExpiresAt > DateTime.UtcNow), Muting = mute != default && (mute.ExpiresAt == default || mute.ExpiresAt > DateTime.UtcNow),
MutingNotifications = mute?.HideNotifications == true, MutingNotifications = mute?.HideNotifications == true,
DomainBlocking = domainBlocked, DomainBlocking = domainBlocked,
Following = following?.State == FollowState.Accepted, Following = following?.State == FollowState.Accepted || reading,
Requested = following?.State == FollowState.Requested, Requested = following?.State == FollowState.Requested,
ShowingReblogs = following?.ShowReblogs ?? false, ShowingReblogs = following?.ShowReblogs ?? false,
FollowedBy = followedBy?.IsAccepted == true, FollowedBy = followedBy?.IsAccepted == true,
@@ -105,15 +105,30 @@ namespace PrivaPub.Api.Mastodon.Controllers
mine.UnionWith((await _dbEntities.Posts.Match(p => p.AuthorAccountId == MyId && ids.Contains(p.ReblogOfPostId) && !p.DeletedAt.HasValue).ExecuteAsync(token)) mine.UnionWith((await _dbEntities.Posts.Match(p => p.AuthorAccountId == MyId && ids.Contains(p.ReblogOfPostId) && !p.DeletedAt.HasValue).ExecuteAsync(token))
.Select(p => p.ReblogOfPostId)); .Select(p => p.ReblogOfPostId));
var authors = candidates.Select(p => p.AuthorAccountId ?? p.GroupUserId).Where(a => a != default).Distinct().ToList(); var authors = candidates.Select(p => p.AuthorAccountId ?? p.GroupUserId).Where(a => a != default).Distinct().ToList();
var indexable = (await _dbEntities.Avatars.Match(a => authors.Contains(a.ID) && a.Settings.IsIndexable).ExecuteAsync(token)).Select(a => a.ID) var indexable = (await _dbEntities.Avatars.Match(a => authors.Contains(a.ID) && a.Settings.IsIndexable).ExecuteAsync(token)).Select(a => a.ID).ToHashSet();
.Concat((await _dbEntities.ForeignAvatars.Match(f => authors.Contains(f.ID) && f.IsIndexable).ExecuteAsync(token)).Select(f => f.ID)) // a remote author's searchableBy (FEP-268d) outranks its indexable, and a post's own outranks both
.ToHashSet(); var remoteAuthors = (await _dbEntities.ForeignAvatars.Match(f => authors.Contains(f.ID)).ExecuteAsync(token)).ToDictionary(f => f.ID);
var stating = remoteAuthors.Values.Where(f => f.SearchableBy != default).Select(f => f.ActorURI)
.Concat(candidates.Where(p => p.SearchableBy != default).Select(p => p.ActorURI))
.Where(uri => uri != default).Distinct().ToList();
var followed = stating.Count == 0
? new HashSet<string>()
: (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && stating.Contains(f.TargetActorURI) && f.State == Models.Social.FollowState.Accepted)
.ExecuteAsync(token)).Select(f => f.TargetActorURI).ToHashSet();
bool Searchable(PostEntity post, string author)
{
var remote = remoteAuthors.GetValueOrDefault(author);
var rule = post.SearchableBy ?? remote?.SearchableBy;
if (rule != default)
return Federation.Objects.ObjectShapes.Searchable(rule, remote?.FollowersURL, followed.Contains(post.ActorURI ?? string.Empty));
return remote?.IsIndexable == true || indexable.Contains(author);
}
var found = new List<PostEntity>(); var found = new List<PostEntity>();
foreach (var post in candidates) foreach (var post in candidates)
{ {
var author = post.AuthorAccountId ?? post.GroupUserId; var author = post.AuthorAccountId ?? post.GroupUserId;
var reachable = author == MyId && !post.IsFederatedCopy || mine.Contains(post.ID) || post.Mentions.Any(m => m.AccountId == MyId) var reachable = author == MyId && !post.IsFederatedCopy || mine.Contains(post.ID) || post.Mentions.Any(m => m.AccountId == MyId)
|| post.Visibility == PostVisibility.Public && indexable.Contains(author); || post.Visibility == PostVisibility.Public && Searchable(post, author);
if (reachable && await VisibilityPolicy.CanSee(post, MyId, token)) if (reachable && await VisibilityPolicy.CanSee(post, MyId, token))
found.Add(post); found.Add(post);
if (found.Count >= offset + limit) if (found.Count >= offset + limit)
@@ -333,6 +333,13 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpPost("/api/v1/statuses/{id}/unfavourite"), Scope("write:favourites")] [HttpPost("/api/v1/statuses/{id}/unfavourite"), Scope("write:favourites")]
public Task<IActionResult> Unfavourite(string id, CancellationToken token) => Toggle(_statuses.Favourite(Me, id, false, token), id, token); public Task<IActionResult> Unfavourite(string id, CancellationToken token) => Toggle(_statuses.Favourite(Me, id, false, token), id, token);
// PrivaPub's own, for the threadiverse's downvotes (a favourite is the upvote)
[HttpPost("/api/v1/statuses/{id}/downvote"), Scope("write:favourites")]
public Task<IActionResult> Downvote(string id, CancellationToken token) => Toggle(_statuses.Downvote(Me, id, true, token), id, token);
[HttpPost("/api/v1/statuses/{id}/undownvote"), Scope("write:favourites")]
public Task<IActionResult> Undownvote(string id, CancellationToken token) => Toggle(_statuses.Downvote(Me, id, false, token), id, token);
[HttpPost("/api/v1/statuses/{id}/reblog"), Scope("write:statuses")] [HttpPost("/api/v1/statuses/{id}/reblog"), Scope("write:statuses")]
public async Task<IActionResult> Reblog(string id, CancellationToken token) public async Task<IActionResult> Reblog(string id, CancellationToken token)
{ {
@@ -64,6 +64,26 @@ namespace PrivaPub.Api.Mastodon.Controllers
return await Respond(query, "/api/v1/timelines/public", token); return await Respond(query, "/api/v1/timelines/public", token);
} }
// PrivaPub's own: a feed the persona reads (Lemmy's multi-community, PieFed's feed), its communities' threads,
// newest first, as the server's reader of feeds brings them in (owner decision 2026-10-07)
[HttpGet("/api/v1/timelines/feed/{id}"), Scope("read:statuses")]
public async Task<IActionResult> Feed(string id, CancellationToken token)
{
var feed = await _dbEntities.ForeignAvatars.MatchID(id).ExecuteFirstAsync(token);
if (feed is not { AvatarType: Models.User.AvatarType.Feed }
|| !await DB.Default.Find<FeedSubscription>().Match(s => s.AvatarId == MyId && s.FeedActorURI == feed.ActorURI).ExecuteAnyAsync(token))
return NotFoundError();
var communities = feed.FeedCommunities;
var ours = Me.BaseAddress + "/peasants/";
var names = communities.Where(c => c.StartsWith(ours, StringComparison.Ordinal)).Select(c => c[ours.Length..]).ToList();
var groups = names.Count == 0
? new List<string>()
: (await _dbEntities.Groups.Match(g => names.Contains(g.UserName) && !g.DeletionAt.HasValue).ExecuteAsync(token)).Select(g => g.ID).ToList();
var query = _dbEntities.Posts.Match(p => (communities.Contains(p.AudienceURI) || groups.Contains(p.GroupId))
&& p.ReblogOfPostId == null && p.InReplyToURI == null).Match(VisibilityPolicy.IsPublic);
return await Respond(query, $"/api/v1/timelines/feed/{id}", token);
}
[HttpGet("/api/v1/timelines/tag/{hashtag}"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] [HttpGet("/api/v1/timelines/tag/{hashtag}"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Tag(string hashtag, CancellationToken token) public async Task<IActionResult> Tag(string hashtag, CancellationToken token)
{ {
@@ -217,7 +237,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
[NotificationType.Update] = "update", [NotificationType.Update] = "update",
[NotificationType.Poll] = "poll", [NotificationType.Poll] = "poll",
[NotificationType.Reaction] = "pleroma:emoji_reaction", [NotificationType.Reaction] = "pleroma:emoji_reaction",
[NotificationType.Quote] = "quote" [NotificationType.Quote] = "quote",
[NotificationType.ModerationWarning] = "moderation_warning"
}; };
readonly MastodonMapper _mapper; readonly MastodonMapper _mapper;
@@ -317,7 +338,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
sample_account_ids = group.Select(n => mapped[n.ID].Account.Id).Distinct().Take(8).ToList(), sample_account_ids = group.Select(n => mapped[n.ID].Account.Id).Distinct().Take(8).ToList(),
status_id = newest.Status?.Id, status_id = newest.Status?.Id,
emoji = newest.Emoji, emoji = newest.Emoji,
emoji_url = newest.EmojiUrl emoji_url = newest.EmojiUrl,
moderation_warning = newest.ModerationWarning
}); });
} }
return new return new
@@ -79,6 +79,7 @@
public PrivaPubPlace Place { get; set; }//a remote post's own place (Pixelfed), shown only public PrivaPubPlace Place { get; set; }//a remote post's own place (Pixelfed), shown only
public Account Wall { get; set; }//whose wall it was written on (FEP-400e), when not its author's public Account Wall { get; set; }//whose wall it was written on (FEP-400e), when not its author's
public PrivaPubVotes Votes { get; set; } = new(); public PrivaPubVotes Votes { get; set; } = new();
public List<PrivaPubFlair> Flairs { get; set; } = new();
} }
public class PrivaPubSource public class PrivaPubSource
@@ -163,10 +164,23 @@
public string Url { get; set; } public string Url { get; set; }
} }
public class PrivaPubFlair
{
public string Id { get; set; }
public string Name { get; set; }
public string Slug { get; set; }
public string Description { get; set; }
public string Color { get; set; }
public string TextColor { get; set; }
public string BackgroundColor { get; set; }
public bool BlurImages { get; set; }
}
public class PrivaPubVotes public class PrivaPubVotes
{ {
public int Up { get; set; } public int Up { get; set; }
public int Down { get; set; } public int Down { get; set; }
public bool? Downvoted { get; set; }//the viewer's own downvote; null with no viewer
} }
public class QuoteEntity public class QuoteEntity
@@ -405,6 +419,18 @@
public Status Status { get; set; } public Status Status { get; set; }
public string Emoji { get; set; } public string Emoji { get; set; }
public string EmojiUrl { get; set; } public string EmojiUrl { get; set; }
public AccountWarning ModerationWarning { get; set; }
}
public class AccountWarning
{
public string Id { get; set; }
public string Action { get; set; } = "none";
public string Text { get; set; } = string.Empty;
public List<string> StatusIds { get; set; } = new();
public Account TargetAccount { get; set; }
public object Appeal { get; set; }
public string CreatedAt { get; set; }
} }
public class Context public class Context
@@ -49,6 +49,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
? new List<PostEntity>() ? new List<PostEntity>()
: await _dbEntities.Posts.Match(p => postIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token); : await _dbEntities.Posts.Match(p => postIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token);
var statuses = (await Statuses(posts, viewerId, token)).ToDictionary(s => s.Id); var statuses = (await Statuses(posts, viewerId, token)).ToDictionary(s => s.Id);
var warned = notifications.Any(n => n.Type == NotificationType.ModerationWarning) && viewerId != default ? await Account(viewerId, token) : default;
return notifications return notifications
.Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty) && (n.PostId == default || statuses.ContainsKey(n.PostId))) .Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty) && (n.PostId == default || statuses.ContainsKey(n.PostId)))
.Select(n => new Entities.Notification .Select(n => new Entities.Notification
@@ -59,9 +60,17 @@ namespace PrivaPub.Api.Mastodon.Mappers
//the group it belongs to by default, so a notification told alone (v1, a stream) joins its group in a client //the group it belongs to by default, so a notification told alone (v1, a stream) joins its group in a client
GroupKey = Controllers.NotificationsController.GroupKey(n, Controllers.NotificationsController.DefaultGrouped), GroupKey = Controllers.NotificationsController.GroupKey(n, Controllers.NotificationsController.DefaultGrouped),
Account = accounts[n.FromAccountId], Account = accounts[n.FromAccountId],
Status = n.PostId == default ? default : statuses[n.PostId], Status = n.PostId == default || n.Type == NotificationType.ModerationWarning ? default : statuses[n.PostId],
Emoji = n.Emoji == default ? default : n.EmojiURL == default ? n.Emoji : $":{n.Emoji}:", Emoji = n.Emoji == default ? default : n.EmojiURL == default ? n.Emoji : $":{n.Emoji}:",
EmojiUrl = Proxied(n.EmojiURL) EmojiUrl = Proxied(n.EmojiURL),
ModerationWarning = n.Type != NotificationType.ModerationWarning ? default : new AccountWarning
{
Id = n.ID,
Text = n.Text ?? string.Empty,
StatusIds = n.PostId == default ? new List<string>() : new List<string> { n.PostId },
TargetAccount = warned,
CreatedAt = MastodonJson.Time(n.CreatedAt)
}
}) })
.ToList(); .ToList();
} }
@@ -209,6 +218,9 @@ namespace PrivaPub.Api.Mastodon.Mappers
var favourited = viewerId == default var favourited = viewerId == default
? new HashSet<string>() ? new HashSet<string>()
: (await _dbEntities.Favourites.Match(f => f.AccountId == viewerId && ids.Contains(f.PostId)).ExecuteAsync(token)).Select(f => f.PostId).ToHashSet(); : (await _dbEntities.Favourites.Match(f => f.AccountId == viewerId && ids.Contains(f.PostId)).ExecuteAsync(token)).Select(f => f.PostId).ToHashSet();
var downvoted = viewerId == default
? new HashSet<string>()
: (await DB.Default.Find<Downvote>().Match(d => d.AccountId == viewerId && ids.Contains(d.PostId)).ExecuteAsync(token)).Select(d => d.PostId).ToHashSet();
var bookmarked = viewerId == default var bookmarked = viewerId == default
? new HashSet<string>() ? new HashSet<string>()
: (await DB.Default.Find<Bookmark>().Match(b => b.AvatarId == viewerId && ids.Contains(b.PostId)).ExecuteAsync(token)).Select(b => b.PostId).ToHashSet(); : (await DB.Default.Find<Bookmark>().Match(b => b.AvatarId == viewerId && ids.Contains(b.PostId)).ExecuteAsync(token)).Select(b => b.PostId).ToHashSet();
@@ -277,7 +289,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
Card = Card(post), Card = Card(post),
Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)), Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)),
Emojis = Emojis(post.Emojis), Emojis = Emojis(post.Emojis),
Privapub = Extension(post, accounts), Privapub = Extension(post, accounts, viewerId == default ? default(bool?) : downvoted.Contains(post.ID)),
EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new(), EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new(),
Pleroma = new PleromaStatus { EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new() }, Pleroma = new PleromaStatus { EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new() },
Mentions = post.Mentions.Where(m => !m.Silent).Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(), Mentions = post.Mentions.Where(m => !m.Silent).Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
@@ -480,7 +492,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
List<CustomEmojiEntity> Emojis(IEnumerable<CustomEmoji> emojis) => List<CustomEmojiEntity> Emojis(IEnumerable<CustomEmoji> emojis) =>
emojis?.Select(e => new CustomEmojiEntity { Shortcode = e.Shortcode, Url = Proxied(e.URL), StaticUrl = Proxied(e.URL) }).ToList() ?? new(); emojis?.Select(e => new CustomEmojiEntity { Shortcode = e.Shortcode, Url = Proxied(e.URL), StaticUrl = Proxied(e.URL) }).ToList() ?? new();
PrivaPubStatus Extension(PostEntity post, Dictionary<string, Account> accounts) => new() PrivaPubStatus Extension(PostEntity post, Dictionary<string, Account> accounts, bool? downvoted) => new()
{ {
Wall = post.WallOwnerAccountId == default || post.WallOwnerAccountId == AuthorOf(post) ? default : accounts.GetValueOrDefault(post.WallOwnerAccountId), Wall = post.WallOwnerAccountId == default || post.WallOwnerAccountId == AuthorOf(post) ? default : accounts.GetValueOrDefault(post.WallOwnerAccountId),
ObjectType = post.ObjectType, ObjectType = post.ObjectType,
@@ -534,7 +546,12 @@ namespace PrivaPub.Api.Mastodon.Mappers
{ {
Name = post.Place.Name, Latitude = post.Place.Latitude, Longitude = post.Place.Longitude, Country = post.Place.Country Name = post.Place.Name, Latitude = post.Place.Latitude, Longitude = post.Place.Longitude, Country = post.Place.Country
}, },
Votes = new PrivaPubVotes { Up = post.FavouritesCount, Down = post.DownvotesCount } Votes = new PrivaPubVotes { Up = post.FavouritesCount, Down = post.DownvotesCount, Downvoted = downvoted },
Flairs = post.Flairs?.Select(f => new PrivaPubFlair
{
Id = f.Id, Name = f.Name, Slug = f.Slug, Description = f.Description, Color = f.Color, TextColor = f.TextColor,
BackgroundColor = f.BackgroundColor, BlurImages = f.BlurImages
}).ToList() ?? new()
}; };
PrivaPubVariant Variant(MediaVariant variant) => new() PrivaPubVariant Variant(MediaVariant variant) => new()
@@ -31,7 +31,9 @@ namespace PrivaPub.Controllers.ClientToServer
Forwarded = r.Forwarded, Forwarded = r.Forwarded,
IsResolved = r.IsResolved, IsResolved = r.IsResolved,
CreatedAt = r.CreatedAt, CreatedAt = r.CreatedAt,
ResolvedAt = r.ResolvedAt ResolvedAt = r.ResolvedAt,
RemoteResolvedAt = r.RemoteResolvedAt,
RemoteResolvedBy = r.RemoteResolvedBy
})); }));
[HttpPost, Route("/clientapi/moderator/reports/{id}/resolve")] [HttpPost, Route("/clientapi/moderator/reports/{id}/resolve")]
+134
View File
@@ -0,0 +1,134 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
namespace PrivaPub.Domain.Social
{
public interface IFeedFollows
{
Task<bool> Subscribe(LocalActor me, ForeignAvatar feed, bool on, CancellationToken token);
Task<bool> IsSubscribed(string avatarId, string feedActorUri, CancellationToken token);
Task Reconcile(CancellationToken token);
}
// Feeds read through the server (owner decision 2026-10-07): a persona following a feed is kept here alone, and the
// server's reader (LocalActorService.ReaderUserName) follows every community in the feeds anyone reads, as Lemmy's own
// follower of multi-communities does, and stops following one no feed holds any more. What it follows arrives as any
// community's posts do; nobody's home shows them, the feed's timeline does.
public class FeedFollows : IFeedFollows
{
public const int MaxCommunities = 500;
static readonly TimeSpan Stale = TimeSpan.FromDays(1);
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IFollowService _follows;
readonly IJobQueue _jobs;
public FeedFollows(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IFollowService follows, IJobQueue jobs)
{
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
_follows = follows;
_jobs = jobs;
}
public static Task<bool> Sync(IJobQueue jobs, CancellationToken token) =>
jobs.Enqueue(JobKind.SyncFeeds, string.Empty, default, $"feeds|{Guid.NewGuid():N}", token);
public async Task<bool> Subscribe(LocalActor me, ForeignAvatar feed, bool on, CancellationToken token)
{
if (feed is not { AvatarType: AvatarType.Feed })
return false;
if (on)
try
{
await DB.Default.SaveAsync(new FeedSubscription { AvatarId = me.Id, FeedActorURI = feed.ActorURI, FeedAccountId = feed.ID }, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return true;
}
else if ((await DB.Default.DeleteAsync<FeedSubscription>(s => s.AvatarId == me.Id && s.FeedActorURI == feed.ActorURI)).DeletedCount == 0)
return true;
await Sync(_jobs, token);
return true;
}
public Task<bool> IsSubscribed(string avatarId, string feedActorUri, CancellationToken token) =>
DB.Default.Find<FeedSubscription>().Match(s => s.AvatarId == avatarId && s.FeedActorURI == feedActorUri).ExecuteAnyAsync(token);
public async Task Reconcile(CancellationToken token)
{
var read = (await DB.Default.Find<FeedSubscription, string>().Project(s => s.FeedActorURI).ExecuteAsync(token)).Distinct().ToList();
var feeds = await _dbEntities.ForeignAvatars.Match(a => read.Contains(a.ActorURI) && a.AvatarType == AvatarType.Feed && !a.DeletionAt.HasValue)
.ExecuteAsync(token);
// a feed's communities are as its server listed them a day ago at most (AccountCountsJob reads them, then asks
// for this again)
foreach (var stale in feeds.Where(f => !(DateTime.UtcNow - f.CountedAt < Stale)))
if (Uri.TryCreate(stale.ActorURI, UriKind.Absolute, out var uri))
await _jobs.Enqueue(JobKind.CountAccount, stale.ActorURI, uri.Host.ToLowerInvariant(), AccountCountsJob.DedupeKey(stale.ActorURI, DateTime.UtcNow), token);
var ours = _localActors.BaseAddress + "/";
var wanted = feeds.SelectMany(f => f.FeedCommunities).Where(c => !c.StartsWith(ours, StringComparison.Ordinal))
.Distinct(StringComparer.Ordinal).Take(MaxCommunities).ToHashSet(StringComparer.Ordinal);
var reader = await _localActors.GetReaderActor(token);
var following = await _dbEntities.Followings.Match(f => f.AvatarId == reader.Id).ExecuteAsync(token);
foreach (var gone in following.Where(f => !wanted.Contains(f.TargetActorURI)))
await _follows.UnfollowAs(reader, gone.TargetActorURI, token);
var followed = following.Select(f => f.TargetActorURI).ToHashSet(StringComparer.Ordinal);
foreach (var community in wanted.Where(c => !followed.Contains(c)))
// only a community: a feed lists nothing else, and the reader follows nothing else
if (await _remoteActors.GetActor(community, refresh: false, token) is { AvatarType: AvatarType.Group })
await _follows.FollowAs(reader, community, showReblogs: false, token);
}
}
public class FeedSyncJob(IServiceScopeFactory scopes) : IJobHandler
{
public JobKind Kind => JobKind.SyncFeeds;
public int Concurrency => 1;
public int MaxAttempts => 2;
public int PerHostLimit => 1;
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
{
using var scope = scopes.CreateScope();
await scope.ServiceProvider.GetRequiredService<IFeedFollows>().Reconcile(token);
return JobOutcome.Done;
}
}
// brings the reader's follows up to date with the feeds read here, a minute after start and then every six hours
public sealed class FeedReader(IServiceScopeFactory scopes, ILogger<FeedReader> logger) : BackgroundService
{
static readonly TimeSpan Every = TimeSpan.FromHours(6);
protected override async Task ExecuteAsync(CancellationToken token)
{
await Task.Delay(TimeSpan.FromMinutes(1), token);
using var timer = new PeriodicTimer(Every);
do
{
try
{
using var scope = scopes.CreateScope();
await FeedFollows.Sync(scope.ServiceProvider.GetRequiredService<IJobQueue>(), token);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Feeds could not be brought up to date");
}
}
while (await timer.WaitForNextTickAsync(token));
}
}
}
+2 -1
View File
@@ -119,6 +119,7 @@ namespace PrivaPub.Domain.Social
var following = new Following var following = new Following
{ {
AvatarId = follower.Id, AvatarId = follower.Id,
FollowerKind = follower.Kind == LocalActorKind.Person ? default(LocalActorKind?) : follower.Kind,
TargetActorURI = targetUri, TargetActorURI = targetUri,
TargetAccountId = local?.Id ?? remote.ID, TargetAccountId = local?.Id ?? remote.ID,
TargetIsLocal = local != default, TargetIsLocal = local != default,
@@ -298,7 +299,7 @@ namespace PrivaPub.Domain.Social
.ExecuteAsync(token); .ExecuteAsync(token);
foreach (var following in pending.Where(f => (f.ResentAt ?? f.CreatedAt) + ResendAfter[f.Resent] <= now)) foreach (var following in pending.Where(f => (f.ResentAt ?? f.CreatedAt) + ResendAfter[f.Resent] <= now))
{ {
var follower = await _localActors.FindById(LocalActorKind.Person, following.AvatarId, token); var follower = await _localActors.FindById(following.FollowerKind ?? LocalActorKind.Person, following.AvatarId, token);
var inbox = following.TargetInboxURL ?? (await _remoteActors.GetActor(following.TargetActorURI, refresh: false, token))?.InboxURL; var inbox = following.TargetInboxURL ?? (await _remoteActors.GetActor(following.TargetActorURI, refresh: false, token))?.InboxURL;
if (follower != default && !string.IsNullOrEmpty(inbox)) if (follower != default && !string.IsNullOrEmpty(inbox))
{ {
+2 -1
View File
@@ -8,7 +8,7 @@ namespace PrivaPub.Domain.Social
public static class Notifications public static class Notifications
{ {
public static async Task Add(string avatarId, NotificationType type, string fromAccountId, string fromActorUri, string postId, public static async Task Add(string avatarId, NotificationType type, string fromAccountId, string fromActorUri, string postId,
CancellationToken token, string emoji = default, string emojiUrl = default) CancellationToken token, string emoji = default, string emojiUrl = default, string text = default)
{ {
if (string.IsNullOrEmpty(avatarId) || avatarId == fromAccountId && type != NotificationType.Poll) if (string.IsNullOrEmpty(avatarId) || avatarId == fromAccountId && type != NotificationType.Poll)
return; return;
@@ -28,6 +28,7 @@ namespace PrivaPub.Domain.Social
PostId = postId, PostId = postId,
Emoji = emoji, Emoji = emoji,
EmojiURL = emojiUrl, EmojiURL = emojiUrl,
Text = text,
DedupeKey = emoji == default ? $"{type}|{avatarId}|{fromActorUri}|{postId}" : $"{type}|{avatarId}|{fromActorUri}|{postId}|{emoji}", DedupeKey = emoji == default ? $"{type}|{avatarId}|{fromActorUri}|{postId}" : $"{type}|{avatarId}|{fromActorUri}|{postId}|{emoji}",
Filtered = action == NotificationAction.Filter Filtered = action == NotificationAction.Filter
}; };
+90 -2
View File
@@ -64,6 +64,7 @@ namespace PrivaPub.Domain.Statuses
Task<StatusOutcome> Edit(LocalActor author, string postId, StatusDraft draft, CancellationToken token); Task<StatusOutcome> Edit(LocalActor author, string postId, StatusDraft draft, CancellationToken token);
Task<StatusOutcome> Remove(LocalActor author, string postId, CancellationToken token); Task<StatusOutcome> Remove(LocalActor author, string postId, CancellationToken token);
Task<StatusOutcome> Favourite(LocalActor me, string postId, bool on, CancellationToken token); Task<StatusOutcome> Favourite(LocalActor me, string postId, bool on, CancellationToken token);
Task<StatusOutcome> Downvote(LocalActor me, string postId, bool on, CancellationToken token);
Task<StatusOutcome> Reblog(LocalActor me, string postId, bool on, PostVisibility visibility, CancellationToken token); Task<StatusOutcome> Reblog(LocalActor me, string postId, bool on, PostVisibility visibility, CancellationToken token);
} }
@@ -438,6 +439,9 @@ namespace PrivaPub.Domain.Statuses
var likePermission = on ? await Permission(post, me, InteractionKind.Like, token) : QuotePermission.Granted; var likePermission = on ? await Permission(post, me, InteractionKind.Like, token) : QuotePermission.Granted;
if (likePermission == QuotePermission.Denied) if (likePermission == QuotePermission.Denied)
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Its author does not take likes from you"); return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Its author does not take likes from you");
// one vote a post: an upvote replaces the downvote, here and, by its Like alone, there
if (on)
await Forget<Downvote>(d => d.PostId == post.ID && d.ActorURI == me.Uri, post, p => p.DownvotesCount, token);
// each favourite is a Like of its own, as Mastodon's are: a favourite after an unfavourite is a new Like, which no // each favourite is a Like of its own, as Mastodon's are: a favourite after an unfavourite is a new Like, which no
// server (and no delivery queue) takes for the one already undone; an unfavourite undoes the Like it ends // server (and no delivery queue) takes for the one already undone; an unfavourite undoes the Like it ends
@@ -472,7 +476,7 @@ namespace PrivaPub.Domain.Statuses
post.FavouritesCount--; post.FavouritesCount--;
} }
if (post.IsFederatedCopy && await AuthorInbox(post, token) is { } inbox) if (post.IsFederatedCopy && await VoteInboxes(post, token) is { Count: > 0 } inboxes)
{ {
var like = new JsonObject var like = new JsonObject
{ {
@@ -485,11 +489,95 @@ namespace PrivaPub.Domain.Statuses
if (on && favourite.Approval == ApprovalState.Pending) if (on && favourite.Approval == ApprovalState.Pending)
await _approvals.Ask(me, post, InteractionKind.Like, like, favourite.ID, token); await _approvals.Ask(me, post, InteractionKind.Like, like, favourite.ID, token);
else else
await _delivery.Enqueue(me, new[] { inbox }, on ? like : Undo(me, like, $"undo-like-{favourite.ID}"), token); await _delivery.Enqueue(me, inboxes, on ? like : Undo(me, like, $"undo-like-{favourite.ID}"), token);
} }
return new StatusOutcome(post); return new StatusOutcome(post);
} }
// A downvote, as the threadiverse counts them (Lemmy, PieFed): a `Dislike` to the post's author and its community,
// `Undo{Dislike}` to take it back. One vote a post: a downvote replaces the upvote by its Dislike alone, as Lemmy
// sends a changed vote, since an Undo{Like} sent beside it could arrive after it and take the downvote away (Mbin,
// which keeps a favourite apart, keeps it). Public, as a favourite is (owner decision: the client tells each
// persona once).
public async Task<StatusOutcome> Downvote(LocalActor me, string postId, bool on, CancellationToken token)
{
var post = await Visible(me, postId, token);
if (post?.ReblogOfPostId != default)
post = await Visible(me, post.ReblogOfPostId, token);
if (post == default)
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
Downvote downvote;
if (on)
{
await Forget<Favourite>(f => f.AccountId == me.Id && f.PostId == post.ID, post, p => p.FavouritesCount, token);
downvote = new Downvote { ID = ObjectId.GenerateNewId().ToString(), AccountId = me.Id, ActorURI = me.Uri, PostId = post.ID };
downvote.ActivityURI = me.ActivityUri($"dislike-{downvote.ID}");
try
{
await DB.Default.SaveAsync(downvote, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return new StatusOutcome(post);
}
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.DownvotesCount, 1)).ExecuteAsync(token);
post.DownvotesCount++;
}
else
{
downvote = await DB.Default.Find<Downvote>().Match(d => d.PostId == post.ID && d.ActorURI == me.Uri).ExecuteFirstAsync(token);
if (downvote == default || (await DB.Default.DeleteAsync<Downvote>(downvote.ID)).DeletedCount == 0)
return new StatusOutcome(post);
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.DownvotesCount, -1)).ExecuteAsync(token);
post.DownvotesCount--;
}
if (post.IsFederatedCopy && await VoteInboxes(post, token) is { Count: > 0 } inboxes)
{
var dislike = new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = downvote.ActivityURI,
["type"] = "Dislike",
["actor"] = me.Uri,
["object"] = post.ObjectURI
};
await _delivery.Enqueue(me, inboxes, on ? dislike : Undo(me, dislike, $"undo-dislike-{downvote.ID}"), token);
}
return new StatusOutcome(post);
}
// the persona's other vote on the post, dropped here alone: the vote that replaces it says so there
async Task Forget<TVote>(System.Linq.Expressions.Expression<Func<TVote, bool>> mine, PostEntity post,
System.Linq.Expressions.Expression<Func<PostEntity, int>> count, CancellationToken token) where TVote : Entity
{
var vote = await DB.Default.Find<TVote>().Match(mine).ExecuteFirstAsync(token);
if (vote == default || (await DB.Default.DeleteAsync<TVote>(vote.ID)).DeletedCount == 0)
return;
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(count, -1)).ExecuteAsync(token);
if (typeof(TVote) == typeof(Favourite))
post.FavouritesCount--;
else
post.DownvotesCount--;
}
// where a vote on a remote post goes: the community it was made in, which counts the votes and passes them on, as
// Lemmy sends one; and its author when on another server (a Mastodon account's post in a Lemmy community). One copy a
// server: PieFed drops the second it is sent within a minute, though its first may yet fail.
async Task<List<string>> VoteInboxes(PostEntity post, CancellationToken token)
{
var inboxes = new List<string>();
if (await Federation.Inbox.Communities.Of(post, _dbEntities, token) is { } communityUri
&& await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == communityUri && f.AvatarType == AvatarType.Group).ExecuteFirstAsync(token) is { } community
&& !string.IsNullOrEmpty(community.InboxURL))
inboxes.Add(community.InboxURL);
if (await AuthorInbox(post, token) is { } author
&& !inboxes.Any(i => string.Equals(new Uri(i).Authority, new Uri(author).Authority, StringComparison.OrdinalIgnoreCase)))
inboxes.Add(author);
return inboxes;
}
// how a remote post's interaction policy takes an interaction of ours: at once, once asked, or not at all // how a remote post's interaction policy takes an interaction of ours: at once, once asked, or not at all
async Task<QuotePermission> Permission(PostEntity target, LocalActor actor, InteractionKind kind, CancellationToken token) => async Task<QuotePermission> Permission(PostEntity target, LocalActor actor, InteractionKind kind, CancellationToken token) =>
target is { IsFederatedCopy: true } && _approvals != default ? await _approvals.Judge(target, actor, kind, token) : QuotePermission.Granted; target is { IsFederatedCopy: true } && _approvals != default ? await _approvals.Judge(target, actor, kind, token) : QuotePermission.Granted;
+3 -2
View File
@@ -40,13 +40,14 @@ namespace PrivaPub.Domain.Timelines
{ {
case PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly: case PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly:
var followers = post.IsFederatedCopy var followers = post.IsFederatedCopy
? await _dbEntities.Followings.Match(f => f.TargetActorURI == post.ActorURI && f.State == FollowState.Accepted).ExecuteAsync(token) ? await _dbEntities.Followings.Match(f => f.TargetActorURI == post.ActorURI && f.State == FollowState.Accepted && f.FollowerKind == null).ExecuteAsync(token)
: await _dbEntities.Followings.Match(f => f.TargetAccountId == post.GroupUserId && f.TargetIsLocal && f.State == FollowState.Accepted).ExecuteAsync(token); : await _dbEntities.Followings.Match(f => f.TargetAccountId == post.GroupUserId && f.TargetIsLocal && f.State == FollowState.Accepted).ExecuteAsync(token);
foreach (var following in followers) foreach (var following in followers)
if (await Shows(following, post, token)) if (await Shows(following, post, token))
recipients.Add(following.AvatarId); recipients.Add(following.AvatarId);
if (!string.IsNullOrEmpty(post.AudienceURI)) if (!string.IsNullOrEmpty(post.AudienceURI))
foreach (var member in await _dbEntities.Followings.Match(f => f.TargetActorURI == post.AudienceURI && f.State == FollowState.Accepted).ExecuteAsync(token)) // (the server's reader of feeds follows communities for nobody's home)
foreach (var member in await _dbEntities.Followings.Match(f => f.TargetActorURI == post.AudienceURI && f.State == FollowState.Accepted && f.FollowerKind == null).ExecuteAsync(token))
recipients.Add(member.AvatarId); recipients.Add(member.AvatarId);
// a post on a wall (FEP-400e): its owner, when a persona, and those following the owner // a post on a wall (FEP-400e): its owner, when a persona, and those following the owner
if (!string.IsNullOrEmpty(post.WallOwnerURI)) if (!string.IsNullOrEmpty(post.WallOwnerURI))
+53 -15
View File
@@ -12,18 +12,25 @@ namespace PrivaPub.Federation.Actors
// A remote account's follower, following and post counts, as its own server publishes them (the totalItems of its // A remote account's follower, following and post counts, as its own server publishes them (the totalItems of its
// followers, following and outbox collections), and the posts it features. Read when the account is fetched, at most // followers, following and outbox collections), and the posts it features. 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 // once a day, never when someone looks at it; a hidden or missing collection leaves its count unknown. Mastodon and
// GoToSocial do the same. // GoToSocial do the same. A feed's following is its communities: they are kept, and read in turn, so its list shows.
public class AccountCountsJob : IJobHandler public class AccountCountsJob : IJobHandler
{ {
readonly IRemoteActorService _remoteActors; readonly IRemoteActorService _remoteActors;
readonly DbEntities _dbEntities; readonly DbEntities _dbEntities;
readonly IFeaturedPosts _featured; readonly IFeaturedPosts _featured;
readonly ILocalActorService _localActors;
readonly IJobQueue _jobs;
public AccountCountsJob(IRemoteActorService remoteActors, DbEntities dbEntities, IFeaturedPosts featured = default) public const int MaxFeedCommunities = 50;
public AccountCountsJob(IRemoteActorService remoteActors, DbEntities dbEntities, IFeaturedPosts featured = default,
ILocalActorService localActors = default, IJobQueue jobs = default)
{ {
_remoteActors = remoteActors; _remoteActors = remoteActors;
_dbEntities = dbEntities; _dbEntities = dbEntities;
_featured = featured; _featured = featured;
_localActors = localActors;
_jobs = jobs;
} }
public JobKind Kind => JobKind.CountAccount; public JobKind Kind => JobKind.CountAccount;
@@ -38,28 +45,59 @@ namespace PrivaPub.Federation.Actors
var actor = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == job.Payload && !a.DeletionAt.HasValue).ExecuteFirstAsync(token); var actor = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == job.Payload && !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
if (actor == default) if (actor == default)
return JobOutcome.Done; return JobOutcome.Done;
await DB.Default.Update<ForeignAvatar>().MatchID(actor.ID) var feed = actor.AvatarType == AvatarType.Feed;
.Modify(a => a.FollowersCount, await Total(actor, actor.FollowersURL, token)) var (following, communities, listed) = await Read(actor, actor.FollowingURL, feed, token);
.Modify(a => a.FollowingCount, await Total(actor, actor.FollowingURL, token)) var update = DB.Default.Update<ForeignAvatar>().MatchID(actor.ID)
.Modify(a => a.StatusesCount, await Total(actor, actor.OutboxURL, token)) .Modify(a => a.FollowersCount, (await Read(actor, actor.FollowersURL, false, token)).Total)
.Modify(a => a.CountedAt, DateTime.UtcNow) .Modify(a => a.FollowingCount, following)
.ExecuteAsync(token); .Modify(a => a.StatusesCount, (await Read(actor, actor.OutboxURL, false, token)).Total)
.Modify(a => a.CountedAt, DateTime.UtcNow);
// (a feed whose list could not be read keeps the one it had: the reader of feeds would unfollow them all)
if (feed && listed)
update = update.Modify(a => a.FeedCommunities, communities);
await update.ExecuteAsync(token);
if (feed && listed)
{
foreach (var community in communities)
if (_localActors == default || !community.StartsWith(_localActors.BaseAddress + "/", StringComparison.Ordinal))
await _remoteActors.GetActor(community, refresh: false, token);
if (_jobs != default && await DB.Default.Find<Models.Social.FeedSubscription>().Match(s => s.FeedActorURI == actor.ActorURI).ExecuteAnyAsync(token))
await Domain.Social.FeedFollows.Sync(_jobs, token);
}
if (_featured != default) if (_featured != default)
await _featured.Sync(actor, token); await _featured.Sync(actor, token);
return JobOutcome.Done; return JobOutcome.Done;
} }
// only from the actor's own server, as everything we believe about it // only from the actor's own server, as everything we believe about it: its total, and the ids it lists when asked
async Task<int?> Total(ForeignAvatar actor, string collection, CancellationToken token) // (inline, or on its first page)
async Task<(int? Total, List<string> Items, bool Listed)> Read(ForeignAvatar actor, string collection, bool items, CancellationToken token)
{ {
var listed = new List<string>();
if (string.IsNullOrEmpty(collection) || !Origin.Same(collection, actor.ActorURI)) if (string.IsNullOrEmpty(collection) || !Origin.Same(collection, actor.ActorURI))
return default; return (default, listed, false);
using var scope = HttpScope.For("collection"); using var scope = HttpScope.For("collection");
using var fetched = await _remoteActors.FetchObject(collection, token); using var fetched = await _remoteActors.FetchObject(collection, token);
return fetched != default && fetched.Root.ValueKind == System.Text.Json.JsonValueKind.Object if (fetched == default || fetched.Root.ValueKind != System.Text.Json.JsonValueKind.Object)
&& fetched.Root.TryGetProperty("totalItems", out var total) && total.TryGetInt32(out var count) && count >= 0 return (default, listed, false);
? count int? total = fetched.Root.TryGetProperty("totalItems", out var counted) && counted.TryGetInt32(out var count) && count >= 0 ? count : default;
: default; if (!items)
return (total, listed, true);
var document = System.Text.Json.Nodes.JsonNode.Parse(fetched.Root.GetRawText());
var page = document["orderedItems"] ?? document["items"] ?? document["first"]?["orderedItems"] ?? document["first"]?["items"];
if (page == default && ActivityJson.Id(document["first"]) is { } first && Origin.Same(first, actor.ActorURI))
{
using var next = await _remoteActors.FetchObject(first, token);
if (next == default)
return (total, listed, false);
var nextPage = System.Text.Json.Nodes.JsonNode.Parse(next.Root.GetRawText());
page = nextPage?["orderedItems"] ?? nextPage?["items"];
}
if (page is System.Text.Json.Nodes.JsonArray array)
listed = array.Select(ActivityJson.Id)
.Where(id => Uri.TryCreate(id, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http")
.Distinct(StringComparer.Ordinal).Take(MaxFeedCommunities).ToList();
return (total, listed, true);
} }
} }
} }
+10 -2
View File
@@ -11,7 +11,7 @@ namespace PrivaPub.Federation.Actors
public sealed class ActorDocument public sealed class ActorDocument
{ {
static readonly string[] ActorTypes = { "Person", "Service", "Application", "Group", "Organization" }; static readonly string[] ActorTypes = { "Person", "Service", "Application", "Group", "Organization", "Feed" };
public string Id { get; init; } public string Id { get; init; }
public string Type { get; init; } public string Type { get; init; }
@@ -36,6 +36,9 @@ namespace PrivaPub.Federation.Actors
public string IconDescription { get; init; } public string IconDescription { get; init; }
public string HeaderDescription { get; init; } public string HeaderDescription { get; init; }
public string MovedTo { get; init; } public string MovedTo { get; init; }
public string Moderators { get; init; }
public List<PostFlair> Flairs { get; init; } = new();
public List<string> SearchableBy { get; init; }
public List<string> AlsoKnownAs { get; init; } = new();//the accounts it says it also is (a Move's target names the moved one) public List<string> AlsoKnownAs { get; init; } = new();//the accounts it says it also is (a Move's target names the moved one)
public DateTime? Published { get; init; } public DateTime? Published { get; init; }
public List<CustomEmoji> Emojis { get; init; } = new(); public List<CustomEmoji> Emojis { get; init; } = new();
@@ -71,7 +74,8 @@ namespace PrivaPub.Federation.Actors
Features = ActorFeatures.Detect(root), Features = ActorFeatures.Detect(root),
PreferredUsername = RemoteActorService.Text(root, "preferredUsername"), PreferredUsername = RemoteActorService.Text(root, "preferredUsername"),
Name = RemoteActorService.Text(root, "name"), Name = RemoteActorService.Text(root, "name"),
Summary = RemoteActorService.Text(root, "summary"), // (Lemmy's feed keeps its text in `description`)
Summary = RemoteActorService.Text(root, "summary") ?? (type == "Feed" ? RemoteActorService.Text(root, "description") : default),
Url = RemoteActorService.Text(root, "url") ?? id, Url = RemoteActorService.Text(root, "url") ?? id,
Inbox = Origin.Same(inbox, id) ? inbox : default, Inbox = Origin.Same(inbox, id) ? inbox : default,
Outbox = RemoteActorService.Text(root, "outbox"), Outbox = RemoteActorService.Text(root, "outbox"),
@@ -92,6 +96,10 @@ namespace PrivaPub.Federation.Actors
IconDescription = root.TryGetProperty("icon", out var described) ? Alt(described) : default, IconDescription = root.TryGetProperty("icon", out var described) ? Alt(described) : default,
HeaderDescription = root.TryGetProperty("image", out var headerImage) ? Alt(headerImage) : default, HeaderDescription = root.TryGetProperty("image", out var headerImage) ? Alt(headerImage) : default,
MovedTo = RemoteActorService.Text(root, "movedTo"), MovedTo = RemoteActorService.Text(root, "movedTo"),
Flairs = type != "Group" ? new() : ObjectShapes.Flairs(root.TryGetProperty("tag", out var labels) ? JsonNode.Parse(labels.GetRawText()) : default,
root.TryGetProperty("lemmy:tagsForPosts", out var older) ? JsonNode.Parse(older.GetRawText()) : default),
SearchableBy = root.TryGetProperty("searchableBy", out var searchable) ? ObjectShapes.SearchableBy(JsonNode.Parse(searchable.GetRawText())) : default,
Moderators = type == "Group" ? RemoteActorService.Text(root, "attributedTo") ?? RemoteActorService.Text(root, "moderators") : default,
AlsoKnownAs = root.TryGetProperty("alsoKnownAs", out var aliases) ? Uris(aliases) : new(), AlsoKnownAs = root.TryGetProperty("alsoKnownAs", out var aliases) ? Uris(aliases) : new(),
Published = DateTimeOffset.TryParse(RemoteActorService.Text(root, "published"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var published) Published = DateTimeOffset.TryParse(RemoteActorService.Text(root, "published"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var published)
? published.UtcDateTime ? published.UtcDateTime
@@ -51,7 +51,7 @@ namespace PrivaPub.Federation.Actors
public string Wall => $"{Uri}/graffiti"; public string Wall => $"{Uri}/graffiti";
public bool HasWall => Kind == LocalActorKind.Person && !IsCircle && IsFederated; public bool HasWall => Kind == LocalActorKind.Person && !IsCircle && IsFederated;
// the server's own actors (the instance actor, the reporter): nobody follows, mentions or looks them up as accounts // the server's own actors (the instance actor, the reporter): nobody follows, mentions or looks them up as accounts
public bool IsServerActor => Kind is LocalActorKind.Application or LocalActorKind.Reporter; public bool IsServerActor => Kind is LocalActorKind.Application or LocalActorKind.Reporter or LocalActorKind.Reader;
public string Flock => $"{Uri}/flock"; public string Flock => $"{Uri}/flock";
public string Wardens => $"{Uri}/wardens"; public string Wardens => $"{Uri}/wardens";
public string SharedInbox => $"{BaseAddress}/human-centipede"; public string SharedInbox => $"{BaseAddress}/human-centipede";
@@ -74,6 +74,7 @@ namespace PrivaPub.Federation.Actors
Task<LocalActor> FindByAddress(string address, CancellationToken token); Task<LocalActor> FindByAddress(string address, CancellationToken token);
Task<LocalActor> GetInstanceActor(CancellationToken token); Task<LocalActor> GetInstanceActor(CancellationToken token);
Task<LocalActor> GetReporterActor(CancellationToken token); Task<LocalActor> GetReporterActor(CancellationToken token);
Task<LocalActor> GetReaderActor(CancellationToken token);
Task<bool> IsUserNameTaken(string userName, CancellationToken token); Task<bool> IsUserNameTaken(string userName, CancellationToken token);
Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token); Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token);
LocalActor FromAvatar(Avatar avatar); LocalActor FromAvatar(Avatar avatar);
@@ -89,13 +90,17 @@ namespace PrivaPub.Federation.Actors
// the anonymous Service that carries this server's reports to Lemmy, which takes no report from an Application // the anonymous Service that carries this server's reports to Lemmy, which takes no report from an Application
// (owner decision 2026-10-06): one actor for the server, never one per persona // (owner decision 2026-10-06): one actor for the server, never one per persona
public const string ReporterUserName = "privapub_reports"; public const string ReporterUserName = "privapub_reports";
// the anonymous Service that follows the communities of the feeds personas read, so no community learns which persona
// reads it (owner decision 2026-10-07); a Service, since Lemmy takes a follow from no Application
public const string ReaderUserName = "privapub_feeds";
public static bool IsServerActorName(string userName) => public static bool IsServerActorName(string userName) =>
string.Equals(userName, InstanceUserName, StringComparison.OrdinalIgnoreCase) || string.Equals(userName, ReporterUserName, StringComparison.OrdinalIgnoreCase); string.Equals(userName, InstanceUserName, StringComparison.OrdinalIgnoreCase) || string.Equals(userName, ReporterUserName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(userName, ReaderUserName, StringComparison.OrdinalIgnoreCase);
static readonly HashSet<string> ReservedByInstance = new(StringComparer.Ordinal) static readonly HashSet<string> ReservedByInstance = new(StringComparer.Ordinal)
{ {
InstanceUserName, ReporterUserName, "admin", "administrator", "root", "system", "support", "help", "moderator", "mod", InstanceUserName, ReporterUserName, ReaderUserName, "admin", "administrator", "root", "system", "support", "help", "moderator", "mod",
"abuse", "postmaster", "webmaster", "hostmaster", "security", "noreply", "no_reply", "null", "undefined" "abuse", "postmaster", "webmaster", "hostmaster", "security", "noreply", "no_reply", "null", "undefined"
}; };
@@ -103,6 +108,7 @@ namespace PrivaPub.Federation.Actors
readonly IOptionsMonitor<AppConfiguration> _appConfiguration; readonly IOptionsMonitor<AppConfiguration> _appConfiguration;
InstanceActor _instanceActor; InstanceActor _instanceActor;
ReporterActor _reporterActor; ReporterActor _reporterActor;
ReaderActor _readerActor;
public LocalActorService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration) public LocalActorService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration)
{ {
@@ -121,6 +127,8 @@ namespace PrivaPub.Federation.Actors
return await GetInstanceActor(token); return await GetInstanceActor(token);
if (userName == ReporterUserName) if (userName == ReporterUserName)
return await GetReporterActor(token); return await GetReporterActor(token);
if (userName == ReaderUserName)
return await GetReaderActor(token);
var avatar = await _dbEntities.Avatars var avatar = await _dbEntities.Avatars
.Match(a => a.UserName == userName && !a.DeletionAt.HasValue) .Match(a => a.UserName == userName && !a.DeletionAt.HasValue)
@@ -158,6 +166,8 @@ namespace PrivaPub.Federation.Actors
return group == default ? default : FromGroup(group); return group == default ? default : FromGroup(group);
case LocalActorKind.Reporter: case LocalActorKind.Reporter:
return await GetReporterActor(token); return await GetReporterActor(token);
case LocalActorKind.Reader:
return await GetReaderActor(token);
default: default:
return await GetInstanceActor(token); return await GetInstanceActor(token);
} }
@@ -221,6 +231,36 @@ namespace PrivaPub.Federation.Actors
}; };
} }
public async Task<LocalActor> GetReaderActor(CancellationToken token)
{
var reader = _readerActor ??= await LoadReaderActor(token);
return new LocalActor
{
Id = reader.ID,
Kind = LocalActorKind.Reader,
UserName = ReaderUserName,
Name = $"Feeds read on {new Uri(BaseAddress).Host}",
Summary = "It follows the communities in the feeds this PrivaPub server's people read; it never names who reads them.",
PrivateKeyPem = reader.PrivateKey,
PublicKeyPem = reader.PublicKey,
Discoverable = false,
Published = reader.CreationDate,
BaseAddress = BaseAddress
};
}
async Task<ReaderActor> LoadReaderActor(CancellationToken token)
{
var reader = await _dbEntities.ReaderActors.Sort(r => r.CreationDate, Order.Ascending).ExecuteFirstAsync(token);
if (reader != default)
return reader;
var (privateKey, publicKey) = Keys.NewKeyPair();
reader = new ReaderActor { PrivateKey = privateKey, PublicKey = publicKey };
await DB.Default.SaveAsync(reader, token);
return reader;
}
async Task<ReporterActor> LoadReporterActor(CancellationToken token) async Task<ReporterActor> LoadReporterActor(CancellationToken token)
{ {
var reporter = await _dbEntities.ReporterActors.Sort(r => r.CreationDate, Order.Ascending).ExecuteFirstAsync(token); var reporter = await _dbEntities.ReporterActors.Sort(r => r.CreationDate, Order.Ascending).ExecuteFirstAsync(token);
@@ -17,6 +17,8 @@ namespace PrivaPub.Federation.Actors
public interface IRemoteActorService public interface IRemoteActorService
{ {
Task<FetchedJson> FetchObject(string uri, CancellationToken token); Task<FetchedJson> FetchObject(string uri, CancellationToken token);
// the same, or NotModified while the version `etag` names is still current
Task<FetchedJson> FetchObject(string uri, string etag, CancellationToken token) => FetchObject(uri, token);
Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token); Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token);
Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token); Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token);
bool KeyTemporarilyUnavailable(string keyId) => false; bool KeyTemporarilyUnavailable(string keyId) => false;
@@ -50,13 +52,17 @@ namespace PrivaPub.Federation.Actors
_options = options; _options = options;
} }
public async Task<FetchedJson> FetchObject(string uri, CancellationToken token) public Task<FetchedJson> FetchObject(string uri, CancellationToken token) => FetchObject(uri, default, token);
public async Task<FetchedJson> FetchObject(string uri, string etag, CancellationToken token)
{ {
using var scope = HttpScope.Default("object"); using var scope = HttpScope.Default("object");
var signer = await _localActors.GetInstanceActor(token); var signer = await _localActors.GetInstanceActor(token);
var fetched = await Get(uri, signer, token); var fetched = string.IsNullOrEmpty(etag)
if (fetched == default) ? await Get(uri, signer, token)
return default; : await _http.GetJsonIfChanged(uri, Accept, etag, request => HttpSignatures.Sign(request, signer, body: null), token);
if (fetched == default || fetched.NotModified)
return fetched;
var id = Text(fetched.Root, "id"); var id = Text(fetched.Root, "id");
if (Origin.IsDocumentAt(id, fetched.FinalUri)) if (Origin.IsDocumentAt(id, fetched.FinalUri))
@@ -234,6 +240,9 @@ namespace PrivaPub.Federation.Actors
.Modify(a => a.IsLocked, actor.Locked) .Modify(a => a.IsLocked, actor.Locked)
.Modify(a => a.IsMemorial, actor.Memorial) .Modify(a => a.IsMemorial, actor.Memorial)
.Modify(a => a.MovedToURL, Origin.Of(actor.MovedTo) == default ? default : actor.MovedTo) .Modify(a => a.MovedToURL, Origin.Of(actor.MovedTo) == default ? default : actor.MovedTo)
.Modify(a => a.ModeratorsURL, Origin.Same(actor.Moderators, actor.Id) ? actor.Moderators : default)
.Modify(a => a.Flairs, actor.Flairs)
.Modify(a => a.SearchableBy, actor.SearchableBy)
.Modify(a => a.AlsoKnownAs, actor.AlsoKnownAs) .Modify(a => a.AlsoKnownAs, actor.AlsoKnownAs)
.Modify(a => a.Published, actor.Published) .Modify(a => a.Published, actor.Published)
.Modify(a => a.Emojis, actor.Emojis) .Modify(a => a.Emojis, actor.Emojis)
@@ -532,8 +532,8 @@ namespace PrivaPub.Federation.Controllers
if (HttpMethods.IsGet(Request.Method)) if (HttpMethods.IsGet(Request.Method))
Response.Headers.Vary = "Accept"; Response.Headers.Vary = "Accept";
// SecureMode asks every reader of ActivityPub documents for a signature, except for the server's own actors (the // SecureMode asks every reader of ActivityPub documents for a signature, except for the server's own actors (the
// instance actor, the reporter), whose keys peers need first, and except for browsers, which only get redirected to // instance actor, the reporter, the reader of feeds), whose keys peers need first, and except for browsers, which
// the public pages // only get redirected to the public pages
if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method) && !WantsHtml() && Request.Path.Value != "/" if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method) && !WantsHtml() && Request.Path.Value != "/"
&& !LocalActorService.IsServerActorName(context.RouteData.Values["actor"] as string) && !LocalActorService.IsServerActorName(context.RouteData.Values["actor"] as string)
&& await _fetches.Requester(Request, HttpContext.RequestAborted) == default) && await _fetches.Requester(Request, HttpContext.RequestAborted) == default)
+1
View File
@@ -67,6 +67,7 @@ namespace PrivaPub.Federation.Inbox
LocalActorKind.Group when !local.IsCircle => "group", LocalActorKind.Group when !local.IsCircle => "group",
LocalActorKind.Application => "application", LocalActorKind.Application => "application",
LocalActorKind.Reporter => "reporter", LocalActorKind.Reporter => "reporter",
LocalActorKind.Reader => "reader",
_ => default _ => default
}; };
} }
+25
View File
@@ -1,7 +1,14 @@
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.User;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
using PostEntity = PrivaPub.Models.Post.Post; using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Inbox namespace PrivaPub.Federation.Inbox
@@ -20,5 +27,23 @@ namespace PrivaPub.Federation.Inbox
} }
return default; return default;
} }
// whether an account may act for a community's moderators: anyone on the community's own server (its admins and
// moderators, as Lemmy's verify_mod_or_admin_action allows), or an account its moderators collection lists
public static async Task<bool> Moderates(ForeignAvatar community, string actorUri, IRemoteActorService remoteActors, CancellationToken token)
{
if (actorUri == default || community == default)
return false;
if (Origin.Same(actorUri, community.ActorURI))
return true;
if (string.IsNullOrEmpty(community.ModeratorsURL))
return false;
using var fetched = await remoteActors.FetchObject(community.ModeratorsURL, token);
if (fetched == default)
return false;
var collection = JsonNode.Parse(fetched.Root.GetRawText());
var items = collection?["orderedItems"] ?? collection?["items"] ?? collection?["first"]?["orderedItems"] ?? collection?["first"]?["items"];
return items is JsonArray listed && listed.Any(item => Id(item) == actorUri);
}
} }
} }
@@ -59,7 +59,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{ {
var inner = activity["object"]; var inner = activity["object"];
if (inner is JsonObject && Value(inner, "type") is "Create" or "Update" or "Delete" or "Like" or "Dislike" or "Undo" or "Add" or "Remove" or "Block" or "Lock") if (inner is JsonObject && Value(inner, "type") is "Create" or "Update" or "Delete" or "Like" or "Dislike" or "Undo" or "Add" or "Remove" or "Block" or "Lock" or "Move")
{ {
await GroupActivity(inner, actor, token); await GroupActivity(inner, actor, token);
return; return;
@@ -242,6 +242,9 @@ namespace PrivaPub.Federation.Inbox.Handlers
case "Lock" when objectUri != default: case "Lock" when objectUri != default:
await Lock(objectUri, group, true, token); await Lock(objectUri, group, true, token);
break; break;
case "Move" when objectUri != default && Id(inner["origin"]) == group.ActorURI:
await Move(objectUri, Id(inner["target"]), group, token);
break;
case "Undo" when Value(inner["object"], "type") == "Lock": case "Undo" when Value(inner["object"], "type") == "Lock":
await Lock(Id(inner["object"]?["object"]), group, false, token); await Lock(Id(inner["object"]?["object"]), group, false, token);
break; break;
@@ -305,6 +308,42 @@ namespace PrivaPub.Federation.Inbox.Handlers
Arrival.Accept(locked ? "locked" : "unlocked"); Arrival.Accept(locked ? "locked" : "unlocked");
} }
// A post moved to another community (PieFed's Move{object: post, origin, target}), relayed by the community it leaves,
// which vouches for what is done to its own posts as for a lock; its thread goes with it. A move into a community we
// host is not taken: it would be ours to announce.
async Task Move(string objectUri, string targetUri, ForeignAvatar group, CancellationToken token)
{
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI && !p.DeletedAt.HasValue)
.ExecuteFirstAsync(token);
if (post == default)
{
Arrival.Drop("unknown-object");
return;
}
Arrival.About(visibility: post.Visibility, created: post.CreationDate);
var target = targetUri == default || targetUri == group.ActorURI || await _localActors.FindByUri(targetUri, token) != default
? default
: await _remoteActors.GetActor(targetUri, refresh: false, token);
if (target is not { AvatarType: AvatarType.Group })
{
Arrival.Drop("unknown-target");
return;
}
var thread = new List<string> { post.ID };
var level = thread.ToList();
while (level.Count > 0 && thread.Count < MaxMoved)
{
var parents = level;
level = (await _dbEntities.Posts.Match(p => parents.Contains(p.AnsweringToPostId) && p.AudienceURI == group.ActorURI)
.Project(p => new PostEntity { ID = p.ID }).ExecuteAsync(token)).Select(p => p.ID).ToList();
thread.AddRange(level);
}
await DB.Default.Update<PostEntity>().Match(p => thread.Contains(p.ID)).Modify(p => p.AudienceURI, target.ActorURI).ExecuteAsync(token);
Arrival.Accept("moved");
}
const int MaxMoved = 1000;
// A community bans one of our personas (Lemmy's Block with the community as its target): kept as the community // A community bans one of our personas (Lemmy's Block with the community as its target): kept as the community
// blocking the persona, so its relationship says blocked_by and nothing of the persona's is posted there until the // blocking the persona, so its relationship says blocked_by and nothing of the persona's is posted there until the
// Undo. A ban of someone from another server is no business of ours. // Undo. A ban of someone from another server is no business of ours.
@@ -0,0 +1,66 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Inbox.Handlers
{
// Lemmy 1.0 tells a report's sender that it was dealt with: Resolve{Flag}, the Flag being one we sent
// (`/grunts/flag-<report id>`). It is believed from the server holding what was reported, or the community it was
// reported to, and kept on the report beside our own moderators' resolution, which it never replaces.
public class ResolveHandler : IActivityHandler
{
const string FlagMarker = "/grunts/flag-";
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
public ResolveHandler(DbEntities dbEntities, ILocalActorService localActors)
{
_dbEntities = dbEntities;
_localActors = localActors;
}
public string Type => "Resolve";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{
var flag = activity["object"];
var flagId = Id(flag);
var marker = flagId?.IndexOf(FlagMarker, StringComparison.Ordinal) ?? -1;
if (flag is JsonObject && Value(flag, "type") != "Flag" || marker < 0
|| !flagId.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
{
Arrival.Drop("not-ours");
return;
}
var reportId = flagId[(marker + FlagMarker.Length)..].Split('-')[0];
var report = await DB.Default.Find<Report>().MatchID(reportId).ExecuteFirstAsync(token);
if (report == default)
{
Arrival.Drop("unknown-object");
return;
}
var concerned = new List<string>(report.ObjectURIs) { report.TargetActorURI };
foreach (var post in report.PostIds.Count == 0 ? new List<Models.Post.Post>() : await _dbEntities.Posts.Match(p => report.PostIds.Contains(p.ID)).ExecuteAsync(token))
concerned.Add(await Communities.Of(post, _dbEntities, token));
if (!concerned.Any(uri => Origin.Same(uri, actor.ActorURI)))
{
Arrival.Drop("misattributed");
return;
}
await DB.Default.Update<Report>().MatchID(report.ID)
.Modify(r => r.RemoteResolvedAt, DateTime.UtcNow)
.Modify(r => r.RemoteResolvedBy, actor.ActorURI)
.ExecuteAsync(token);
Arrival.Accept("resolved");
}
}
}
@@ -0,0 +1,86 @@
using MongoDB.Entities;
using PrivaPub.Domain.Social;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Inbox.Handlers
{
// Lemmy 1.0's warning: a community's moderator tells one of our personas, about one of its posts there, why (Warn{to:
// [persona], object: post, summary: reason, audience: community}), sent to the persona's inbox alone. It becomes the
// persona's moderation_warning notification, once the sender is shown to moderate that community and the post is the
// persona's own, in it.
public class WarnHandler : IActivityHandler
{
const int MaxReason = 1000;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
public WarnHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors)
{
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
}
public string Type => "Warn";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{
LocalActor persona = default;
foreach (var uri in Recipients(activity["to"]))
if (await _localActors.FindByUri(uri, token) is { Kind: LocalActorKind.Person } found)
{
persona = found;
break;
}
if (persona == default)
{
Arrival.Drop("not-ours");
return;
}
var communityUri = Id(activity["audience"]);
var community = communityUri == default ? default : await _remoteActors.GetActor(communityUri, refresh: false, token);
if (community is not { AvatarType: AvatarType.Group })
{
Arrival.Drop("unknown-community");
return;
}
var objectUri = Id(activity["object"]);
var post = objectUri == default
? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.GroupUserId == persona.Id && !p.IsFederatedCopy).ExecuteFirstAsync(token);
if (post == default || await Communities.Of(post, _dbEntities, token) != community.ActorURI)
{
Arrival.Drop("unknown-object");
return;
}
Arrival.About(visibility: post.Visibility, created: post.CreationDate, local: persona);
if (!await Communities.Moderates(community, actor.ActorURI, _remoteActors, token))
{
Arrival.Drop("not-moderator");
return;
}
var reason = Value(activity, "summary") ?? Value(activity, "content");
await Notifications.Add(persona.Id, NotificationType.ModerationWarning, community.ID, community.ActorURI, post.ID, token,
text: string.IsNullOrWhiteSpace(reason) ? default : reason.Trim().Length <= MaxReason ? reason.Trim() : reason.Trim()[..MaxReason]);
Arrival.Accept("warned");
}
static IEnumerable<string> Recipients(JsonNode node) => node switch
{
JsonArray array => array.Select(Id).Where(id => id != default),
JsonNode single when Id(single) is { } id => new[] { id },
_ => Enumerable.Empty<string>()
};
}
}
+2
View File
@@ -38,6 +38,8 @@ namespace PrivaPub.Federation.Inbox
post.Audio = note.Audio ?? post.Audio; post.Audio = note.Audio ?? post.Audio;
post.Event = note.Event ?? post.Event; post.Event = note.Event ?? post.Event;
post.Place = note.Place; post.Place = note.Place;
post.Flairs = await RemotePosts.Flairs(note, post.AudienceURI, token);
post.SearchableBy = note.SearchableBy;
if (note.CommentsEnabled is { } enabled) if (note.CommentsEnabled is { } enabled)
post.LockedAt = enabled ? null : post.LockedAt ?? DateTime.UtcNow; post.LockedAt = enabled ? null : post.LockedAt ?? DateTime.UtcNow;
if (!IsEdit(note, post)) if (!IsEdit(note, post))
+11
View File
@@ -106,6 +106,7 @@ namespace PrivaPub.Federation.Inbox
Excerpt = note.Excerpt, Excerpt = note.Excerpt,
Source = note.Source, Source = note.Source,
Poll = note.Poll, Poll = note.Poll,
SearchableBy = note.SearchableBy,
QuotePolicy = note.QuotePolicy, QuotePolicy = note.QuotePolicy,
ReplyPolicy = note.ReplyPolicy, ReplyPolicy = note.ReplyPolicy,
LikePolicy = note.LikePolicy, LikePolicy = note.LikePolicy,
@@ -137,10 +138,20 @@ namespace PrivaPub.Federation.Inbox
EditedAt = note.Updated, EditedAt = note.Updated,
Revisions = note.FormerRepresentations.ToList() Revisions = note.FormerRepresentations.ToList()
}; };
post.Flairs = await Flairs(note, post.AudienceURI, token);
await Episode(post, note, author, token); await Episode(post, note, author, token);
return post; return post;
} }
// a post's flairs, filled in from its community's list
public static async Task<List<PostFlair>> Flairs(NoteDocument note, string communityUri, CancellationToken token)
{
if (note.Flairs.Count == 0)
return default;
var community = communityUri == default ? default : await DB.Default.Find<ForeignAvatar>().Match(f => f.ActorURI == communityUri).ExecuteFirstAsync(token);
return ObjectShapes.Described(note.Flairs, community?.Flairs);
}
static readonly System.Text.RegularExpressions.Regex Href = new(@"href=""([^""]+)""", System.Text.RegularExpressions.RegexOptions.Compiled); static readonly System.Text.RegularExpressions.Regex Href = new(@"href=""([^""]+)""", System.Text.RegularExpressions.RegexOptions.Compiled);
// a post that is only a link to a podcast episode of its own author (Castopod announces each episode so): the // a post that is only a link to a podcast episode of its own author (Castopod announces each episode so): the
+74 -20
View File
@@ -20,9 +20,11 @@ namespace PrivaPub.Federation.Inbox
public sealed record RepliesPayload(string PostId, int Depth); public sealed record RepliesPayload(string PostId, int Depth);
// What was said under a remote post on other servers, read when a persona opens its thread, so the thread shows more // What was said under a remote post on other servers, read when a persona opens its thread, so the thread shows more
// than what happened to reach us. The thread's own collection comes first (FEP-7888's `context`, which Mastodon 4.5+ // than what happened to reach us. The thread's own collections come first: its conversation container (FEP-171b's
// serves with every reply at any depth); otherwise the post's `replies` (PeerTube's `comments`), and the replies' // `contextHistory`, every activity its owner added to the thread), then its posts (FEP-7888's `context`, which
// own, two levels down. Read with the instance actor's signature, never a persona's, a few pages and at most // Mastodon 4.5+, Mitra and NodeBB serve with every reply at any depth), sent the ETag of the last whole read so an
// unchanged thread costs one 304; otherwise the post's `replies` (PeerTube's `comments`, Forte's whole thread), and the
// replies' own, two levels down. Read with the instance actor's signature, never a persona's, a few pages and at most
// MaxItems posts a job; each one is fetched from its own server and stored like any post fetched for a thread // MaxItems posts a job; each one is fetched from its own server and stored like any post fetched for a thread
// (RemotePosts.StoreContext: public and unlisted only, its author checked against its origin). // (RemotePosts.StoreContext: public and unlisted only, its author checked against its origin).
public class RepliesJobHandler : IJobHandler public class RepliesJobHandler : IJobHandler
@@ -74,14 +76,35 @@ namespace PrivaPub.Federation.Inbox
if (await Document(post, token) is not { } document) if (await Document(post, token) is not { } document)
return JobOutcome.Done; return JobOutcome.Done;
if (await Collection(document["context"], token) is { } thread) foreach (var name in new[] { "contextHistory", "context" })
{ {
await StoreAll(thread, token); var uri = Id(document[name]);
// Forte's `context` is the thread's first post
if (uri != default && await _dbEntities.Posts.Match(p => p.ObjectURI == uri).ExecuteAnyAsync(token))
continue;
var known = uri == default ? default : await DB.Default.Find<ThreadCollection>().Match(c => c.URI == uri).ExecuteFirstAsync(token);
var (thread, etag, unchanged) = await Collection(document[name], known?.ETag, token);
if (unchanged)
{
await DB.Default.Update<ThreadCollection>().MatchID(known.ID).Modify(c => c.ReadAt, DateTime.UtcNow).ExecuteAsync(token);
return JobOutcome.Done;
}
if (thread == default)
continue;
var (_, read) = await StoreAll(thread, name == "contextHistory", token);
var whole = read && Counted(thread);
if (uri != default && (known != default || whole && etag != default))
await DB.Default.Update<ThreadCollection>()
.Match(c => c.URI == uri)
.Modify(c => c.ETag, whole ? etag : default)
.Modify(c => c.ReadAt, DateTime.UtcNow)
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
return JobOutcome.Done; return JobOutcome.Done;
} }
if (await Collection(document["replies"] ?? document["comments"], token) is not { } replies) if ((await Collection(document["replies"] ?? document["comments"], default, token)).Collection is not { } replies)
return JobOutcome.Done; return JobOutcome.Done;
var answers = await StoreAll(replies, token); var (answers, _) = await StoreAll(replies, false, token);
if (payload.Depth < MaxDepth) if (payload.Depth < MaxDepth)
await _queue.EnqueueMany(answers.Where(a => a.IsFederatedCopy && a.AnsweringToPostId == post.ID).Take(MaxBranches) await _queue.EnqueueMany(answers.Where(a => a.IsFederatedCopy && a.AnsweringToPostId == post.ID).Take(MaxBranches)
.Select(a => For(a, payload.Depth + 1)), token); .Select(a => For(a, payload.Depth + 1)), token);
@@ -100,35 +123,41 @@ namespace PrivaPub.Federation.Inbox
return fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText()) as JsonObject; return fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText()) as JsonObject;
} }
// a collection as its server serves it now: what a post carries is read again by its id, since an embedded first // a collection as its server serves it now, and its ETag: what a post carries is read again by its id, since an
// page is as old as the post; a value that is no web address (Pleroma's `context` is a tag: URI), or no collection, // embedded first page is as old as the post; a value that is no web address (Pleroma's `context` is a tag: URI), or no
// gives nothing // collection, gives nothing, and one whose server still has the version `etag` names is unchanged
async Task<JsonObject> Collection(JsonNode node, CancellationToken token) async Task<(JsonObject Collection, string ETag, bool Unchanged)> Collection(JsonNode node, string etag, CancellationToken token)
{ {
var uri = Id(node); var uri = Id(node);
string served = default;
if (uri != default && Uri.TryCreate(uri, UriKind.Absolute, out var parsed) && (parsed.Scheme == Uri.UriSchemeHttps || parsed.Scheme == Uri.UriSchemeHttp)) if (uri != default && Uri.TryCreate(uri, UriKind.Absolute, out var parsed) && (parsed.Scheme == Uri.UriSchemeHttps || parsed.Scheme == Uri.UriSchemeHttp))
{ {
using var scope = HttpScope.For("replies"); using var scope = HttpScope.For("replies");
using var fetched = await _remoteActors.FetchObject(uri, token); using var fetched = await _remoteActors.FetchObject(uri, etag, token);
if (fetched?.NotModified == true)
return (default, etag, true);
node = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText()); node = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText());
served = fetched?.ETag;
} }
return node is JsonObject collection && Value(collection, "type") is { } type && CollectionTypes.Contains(type) ? collection : default; return (node is JsonObject collection && Value(collection, "type") is { } type && CollectionTypes.Contains(type) ? collection : default, served, false);
} }
// every post the collection lists, stored when we lack it: its pages in order, at most MaxPages and MaxItems // every post the collection lists, stored when we lack it: its pages in order, at most MaxPages and MaxItems; whether
async Task<List<PostEntity>> StoreAll(JsonObject collection, CancellationToken token) // it was read to its end. A conversation container's items are its owner's Adds of the thread's activities.
async Task<(List<PostEntity> Posts, bool Whole)> StoreAll(JsonObject collection, bool activities, CancellationToken token)
{ {
var posts = new List<PostEntity>(); var posts = new List<PostEntity>();
var seen = 0; var seen = 0;
var page = collection["orderedItems"] is JsonArray || collection["items"] is JsonArray ? collection : await Page(collection["first"], token); var page = collection["orderedItems"] is JsonArray || collection["items"] is JsonArray ? collection : await Page(collection["first"], token);
for (var pages = 0; page != default && pages < MaxPages && seen < MaxItems; pages++) for (var pages = 0; page != default; pages++)
{ {
if (pages == MaxPages)
return (posts, false);
foreach (var item in Items(page)) foreach (var item in Items(page))
{ {
if (++seen > MaxItems) if (++seen > MaxItems)
break; return (posts, false);
// a context may list the activities that made the thread (FEP-f228) rather than its posts var id = activities ? await Added(item, token) : PostId(item);
var id = Value(item, "type") is "Create" or "Update" ? Id(item["object"]) : Id(item);
if (id == default) if (id == default)
continue; continue;
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token) var post = await _dbEntities.Posts.Match(p => p.ObjectURI == id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token)
@@ -138,7 +167,32 @@ namespace PrivaPub.Federation.Inbox
} }
page = page["next"] is { } next ? await Page(next, token) : default; page = page["next"] is { } next ? await Page(next, token) : default;
} }
return posts; return (posts, true);
}
// whether the document itself changes with every post added to the thread, so its ETag is the thread's: it counts
// them (NodeBB), or holds every one with no further page (a short Mastodon thread); a long Mastodon thread's first
// page stays as it was while its last one grows
static bool Counted(JsonObject collection)
{
if (collection["totalItems"] is JsonValue)
return true;
var page = collection["orderedItems"] is JsonArray || collection["items"] is JsonArray ? collection : collection["first"] as JsonObject;
return page != default && (page["orderedItems"] is JsonArray || page["items"] is JsonArray) && page["next"] == default;
}
// a context may list the activities that made the thread (FEP-f228) rather than its posts
static string PostId(JsonNode item) => Value(item, "type") is "Create" or "Update" ? Id(item["object"]) : Id(item);
// the post an activity in a conversation container brought into the thread, added by its owner or (Forte's) not: a
// Create's or an Update's object; a like, a removal or anything else adds no post. Only its id is taken, and the post
// is read from its own server.
async Task<string> Added(JsonNode item, CancellationToken token)
{
var activity = await Page(item, token);
if (activity != default && Value(activity, "type") == "Add")
activity = await Page(activity["object"], token);
return activity != default && Value(activity, "type") is "Create" or "Update" ? Id(activity["object"]) : default;
} }
async Task<JsonObject> Page(JsonNode node, CancellationToken token) async Task<JsonObject> Page(JsonNode node, CancellationToken token)
@@ -40,6 +40,8 @@ namespace PrivaPub.Federation.Objects
public IReadOnlyList<PostMedia> Attachments { get; init; } = Array.Empty<PostMedia>(); public IReadOnlyList<PostMedia> Attachments { get; init; } = Array.Empty<PostMedia>();
public PostSource Source { get; init; } public PostSource Source { get; init; }
public PostPoll Poll { get; init; } public PostPoll Poll { get; init; }
public List<PostFlair> Flairs { get; init; } = new();
public List<string> SearchableBy { get; init; }
public InteractionRule QuotePolicy { get; init; } public InteractionRule QuotePolicy { get; init; }
public InteractionRule ReplyPolicy { get; init; } public InteractionRule ReplyPolicy { get; init; }
public InteractionRule LikePolicy { get; init; } public InteractionRule LikePolicy { get; init; }
@@ -119,6 +121,8 @@ namespace PrivaPub.Federation.Objects
Attachments = Attachments(note), Attachments = Attachments(note),
Source = ObjectShapes.Source(note), Source = ObjectShapes.Source(note),
Poll = ObjectShapes.Poll(note), Poll = ObjectShapes.Poll(note),
Flairs = ObjectShapes.Flairs(note["tag"]),
SearchableBy = ObjectShapes.SearchableBy(note["searchableBy"]),
QuotePolicy = ObjectShapes.QuotePolicy(note), QuotePolicy = ObjectShapes.QuotePolicy(note),
ReplyPolicy = ObjectShapes.Policy(note, "canReply"), ReplyPolicy = ObjectShapes.Policy(note, "canReply"),
LikePolicy = ObjectShapes.Policy(note, "canLike"), LikePolicy = ObjectShapes.Policy(note, "canLike"),
@@ -98,6 +98,60 @@ namespace PrivaPub.Federation.Objects
.Take(MaxEmojis) .Take(MaxEmojis)
.ToList(); .ToList();
const int MaxFlairs = 20;
// FEP-268d: who may find this in a search (Public, the author's followers, nobody else); null when not stated
public static List<string> SearchableBy(JsonNode node) => node switch
{
null => default,
JsonArray array => array.Select(Id).Where(id => id != default).Take(MaxAudience).ToList(),
_ when Id(node) is { } single => new List<string> { single },
_ => new List<string>()
};
public static bool Searchable(List<string> searchableBy, string followers, bool viewerFollows) =>
searchableBy.Any(Addressing.IsPublic) || viewerFollows && searchableBy.Any(w => w == followers || w.EndsWith("/followers", StringComparison.Ordinal));
// a post's flairs, or a community's (its `tag`, and PieFed's older `lemmy:tagsForPosts`), in both dialects
public static List<PostFlair> Flairs(params JsonNode[] lists) =>
lists.SelectMany(Objects)
.Where(t => Value(t, "type") is "CommunityPostTag" or "lemmy:CommunityTag" or "CommunityTag")
.Select(t => new PostFlair
{
Id = Id(t) is { } id && Origin.Of(id) != default ? id : default,
Name = Text(Value(t, "name") ?? Value(t, "display_name") ?? Value(t, "preferredUsername"), 100),
Slug = Text(Value(t, "preferredUsername") ?? Value(t, "display_name"), 100),
Description = Text(Value(t, "content"), 500),
Color = Value(t, "color") is { } slot && FlairSlot().IsMatch(slot) ? slot : default,
TextColor = Hex(Value(t, "textColor") ?? Value(t, "text_color")),
BackgroundColor = Hex(Value(t, "backgroundColor") ?? Value(t, "background_color")),
BlurImages = Bool(t, "blurImages") ?? Bool(t, "blur_images") ?? false
})
.Where(f => f.Name != default)
.DistinctBy(f => f.Id ?? f.Slug)
.Take(MaxFlairs)
.ToList();
// a post's flairs as its community describes them: Lemmy names only the id and slug on the post
public static List<PostFlair> Described(List<PostFlair> onPost, List<PostFlair> ofCommunity)
{
if (onPost == default || onPost.Count == 0)
return default;
foreach (var flair in onPost)
if (ofCommunity?.FirstOrDefault(c => c.Id != default && c.Id == flair.Id || c.Slug == flair.Slug) is { } known)
{
flair.Name = flair.Name == flair.Slug ? known.Name ?? flair.Name : flair.Name;
flair.Description ??= known.Description;
flair.Color ??= known.Color;
flair.TextColor ??= known.TextColor;
flair.BackgroundColor ??= known.BackgroundColor;
flair.BlurImages |= known.BlurImages;
}
return onPost;
}
static string Hex(string value) => value != default && HexColour().IsMatch(value) ? value : default;
public static InteractionRule QuotePolicy(JsonObject note) => Policy(note, "canQuote"); public static InteractionRule QuotePolicy(JsonObject note) => Policy(note, "canQuote");
// one rule of a post's interactionPolicy (GoToSocial, FEP-044f): who may, automatically or once asked; the old names // one rule of a post's interactionPolicy (GoToSocial, FEP-044f): who may, automatically or once asked; the old names
@@ -399,6 +453,12 @@ namespace PrivaPub.Federation.Objects
[GeneratedRegex(@"^[\w+\-.@]+$")] [GeneratedRegex(@"^[\w+\-.@]+$")]
private static partial Regex Shortcode(); private static partial Regex Shortcode();
[GeneratedRegex(@"^color\d{2}$")]
private static partial Regex FlairSlot();
[GeneratedRegex(@"^#[0-9a-fA-F]{3,8}$")]
private static partial Regex HexColour();
[GeneratedRegex("<[^>]*>")] [GeneratedRegex("<[^>]*>")]
private static partial Regex Tags(); private static partial Regex Tags();
@@ -182,6 +182,7 @@ namespace PrivaPub.Federation.Outbox
{ Kind: LocalActorKind.Person } => "person", { Kind: LocalActorKind.Person } => "person",
{ Kind: LocalActorKind.Group } => "group", { Kind: LocalActorKind.Group } => "group",
{ Kind: LocalActorKind.Reporter } => "reporter", { Kind: LocalActorKind.Reporter } => "reporter",
{ Kind: LocalActorKind.Reader } => "reader",
_ => "application" _ => "application"
}, },
Signature = "cavage:rsa-sha256" Signature = "cavage:rsa-sha256"
@@ -126,7 +126,7 @@ namespace PrivaPub.Federation.Rendering
{ {
LocalActorKind.Group => "Group", LocalActorKind.Group => "Group",
LocalActorKind.Application => "Application", LocalActorKind.Application => "Application",
LocalActorKind.Reporter => "Service", LocalActorKind.Reporter or LocalActorKind.Reader => "Service",
_ when actor.IsBot => "Service", _ when actor.IsBot => "Service",
_ => "Person" _ => "Person"
}, },
+7
View File
@@ -15,6 +15,7 @@ namespace PrivaPub.Infrastructure.Data
public static class Indexes public static class Indexes
{ {
public static readonly TimeSpan TombstoneLifetime = TimeSpan.FromDays(90); public static readonly TimeSpan TombstoneLifetime = TimeSpan.FromDays(90);
public static readonly TimeSpan ThreadCollectionLifetime = TimeSpan.FromDays(30);
public static async Task Create(CancellationToken token = default) public static async Task Create(CancellationToken token = default)
{ {
@@ -25,6 +26,7 @@ namespace PrivaPub.Infrastructure.Data
await Plain<Post>(token, p => p.WallURI, p => p.ID);//a persona's wall (FEP-400e) await Plain<Post>(token, p => p.WallURI, p => p.ID);//a persona's wall (FEP-400e)
await Plain<Post>(token, p => p.ConversationId, p => p.ID); await Plain<Post>(token, p => p.ConversationId, p => p.ID);
await Plain<Post>(token, p => p.InReplyToURI); await Plain<Post>(token, p => p.InReplyToURI);
await Plain<Post>(token, p => p.AudienceURI, p => p.ID);//a feed's timeline: the posts of its communities
// who boosted what: a persona's boosts of the posts a page shows were a scan of every post under load // who boosted what: a persona's boosts of the posts a page shows were a scan of every post under load
// (tools/pasture/load.sh), and a post's boosts and replies are read for its counts and context // (tools/pasture/load.sh), and a post's boosts and replies are read for its counts and context
await Plain<Post>(token, p => p.AuthorAccountId, p => p.ReblogOfPostId); await Plain<Post>(token, p => p.AuthorAccountId, p => p.ReblogOfPostId);
@@ -166,6 +168,11 @@ namespace PrivaPub.Infrastructure.Data
await Plain<ObjectRecord>(token, r => r.PostId); await Plain<ObjectRecord>(token, r => r.PostId);
await Unique<DeletedObject>(d => d.ObjectURI, Builders<DeletedObject>.Filter.Gt(d => d.ObjectURI, ""), token); await Unique<DeletedObject>(d => d.ObjectURI, Builders<DeletedObject>.Filter.Gt(d => d.ObjectURI, ""), token);
await DB.Default.Index<DeletedObject>().Key(d => d.DeletedAt, KeyType.Ascending).Option(o => o.ExpireAfter = TombstoneLifetime).CreateAsync(token); await DB.Default.Index<DeletedObject>().Key(d => d.DeletedAt, KeyType.Ascending).Option(o => o.ExpireAfter = TombstoneLifetime).CreateAsync(token);
await DB.Default.Index<FeedSubscription>().Key(s => s.AvatarId, KeyType.Ascending).Key(s => s.FeedActorURI, KeyType.Ascending)
.Option(o => o.Unique = true).CreateAsync(token);
await Plain<FeedSubscription>(token, s => s.FeedActorURI);
await Unique<ThreadCollection>(c => c.URI, Builders<ThreadCollection>.Filter.Gt(c => c.URI, ""), token);
await DB.Default.Index<ThreadCollection>().Key(c => c.ReadAt, KeyType.Ascending).Option(o => o.ExpireAfter = ThreadCollectionLifetime).CreateAsync(token);
await DB.Default.Index<Downvote>().Key(d => d.PostId, KeyType.Ascending).Key(d => d.ActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token); await DB.Default.Index<Downvote>().Key(d => d.PostId, KeyType.Ascending).Key(d => d.ActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token);
await Plain<Downvote>(token, d => d.ActivityURI); await Plain<Downvote>(token, d => d.ActivityURI);
await DB.Default.Index<PollVote>().Key(v => v.PostId, KeyType.Ascending).Key(v => v.ActorURI, KeyType.Ascending).Key(v => v.Choice, KeyType.Ascending) await DB.Default.Index<PollVote>().Key(v => v.PostId, KeyType.Ascending).Key(v => v.ActorURI, KeyType.Ascending).Key(v => v.Choice, KeyType.Ascending)
@@ -0,0 +1,24 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
namespace PrivaPub.Infrastructure.Data.Migrations
{
// the feeds reader's name (LocalActorService.ReaderUserName) is the server's, as the reporter's is: a persona or group
// already holding it would be hidden behind the reader, so the upgrade stops and says so
public class _017_the_reader_has_its_name : IMigration
{
public async Task UpgradeAsync()
{
var held = await DB.Default.Find<ReservedName>().Match(r => r.Name == LocalActorService.ReaderUserName).ExecuteFirstAsync();
if (held is { OwnerKind: LocalActorKind.Reader })
return;
if (held != default)
throw new InvalidOperationException(
$"The name {LocalActorService.ReaderUserName} belongs to a {held.OwnerKind} ({held.OwnerId}); rename it before upgrading");
await DB.Default.SaveAsync(new ReservedName { Name = LocalActorService.ReaderUserName, OwnerKind = LocalActorKind.Reader });
}
}
}
+31 -4
View File
@@ -15,6 +15,9 @@ namespace PrivaPub.Infrastructure.Http
public Uri FinalUri { get; init; } public Uri FinalUri { get; init; }
public JsonDocument Document { get; init; } public JsonDocument Document { get; init; }
public JsonElement Root => Document.RootElement; public JsonElement Root => Document.RootElement;
public string ETag { get; init; }
// the origin answered 304 to an If-None-Match: no document, the one read before still stands
public bool NotModified { get; init; }
public void Dispose() => Document?.Dispose(); public void Dispose() => Document?.Dispose();
} }
@@ -24,6 +27,9 @@ namespace PrivaPub.Infrastructure.Http
bool IsAllowed(Uri target); bool IsAllowed(Uri target);
Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token); Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token);
Task<(int Status, FetchedJson Json)> GetJsonStatus(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token); Task<(int Status, FetchedJson Json)> GetJsonStatus(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token);
/// <summary>The document, or one that is <see cref="FetchedJson.NotModified"/> when its origin still has the version
/// <paramref name="etag"/> names.</summary>
Task<FetchedJson> GetJsonIfChanged(string url, string accept, string etag, Action<HttpRequestMessage> sign, CancellationToken token);
bool FailedTemporarily(string url); bool FailedTemporarily(string url);
Task<(Uri FinalUri, string Html)> GetPage(string url, CancellationToken token); Task<(Uri FinalUri, string Html)> GetPage(string url, CancellationToken token);
Task<HttpResponseMessage> OpenMedia(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token); Task<HttpResponseMessage> OpenMedia(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token);
@@ -291,7 +297,20 @@ namespace PrivaPub.Infrastructure.Http
var exchange = new Exchange(url, HttpScope.Purpose ?? "object"); var exchange = new Exchange(url, HttpScope.Purpose ?? "object");
try try
{ {
return await GetJson(url, accept, sign, exchange, token); return await GetJson(url, accept, sign, default, exchange, token);
}
finally
{
Record(exchange);
}
}
public async Task<FetchedJson> GetJsonIfChanged(string url, string accept, string etag, Action<HttpRequestMessage> sign, CancellationToken token)
{
var exchange = new Exchange(url, HttpScope.Purpose ?? "object");
try
{
return await GetJson(url, accept, sign, etag, exchange, token);
} }
finally finally
{ {
@@ -308,7 +327,7 @@ namespace PrivaPub.Infrastructure.Http
var exchange = new Exchange(url, HttpScope.Purpose ?? "object"); var exchange = new Exchange(url, HttpScope.Purpose ?? "object");
try try
{ {
var json = await GetJson(url, accept, sign, exchange, token); var json = await GetJson(url, accept, sign, default, exchange, token);
return (exchange.Status ?? 0, json); return (exchange.Status ?? 0, json);
} }
finally finally
@@ -317,7 +336,7 @@ namespace PrivaPub.Infrastructure.Http
} }
} }
async Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, Exchange exchange, CancellationToken token) async Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, string etag, Exchange exchange, CancellationToken token)
{ {
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target)) if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
{ {
@@ -338,11 +357,15 @@ namespace PrivaPub.Infrastructure.Http
for (var hop = 0; hop <= MaxRedirects; hop++) for (var hop = 0; hop <= MaxRedirects; hop++)
{ {
using var request = Request(target, accept); using var request = Request(target, accept);
if (!string.IsNullOrEmpty(etag))
request.Headers.TryAddWithoutValidation("If-None-Match", etag);
sign?.Invoke(request); sign?.Invoke(request);
using var response = await _httpClientFactory.CreateClient(ClientName) using var response = await _httpClientFactory.CreateClient(ClientName)
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token); .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
exchange.Answered(response); exchange.Answered(response);
if (response.StatusCode == HttpStatusCode.NotModified && !string.IsNullOrEmpty(etag))
return new FetchedJson { FinalUri = target, ETag = etag, NotModified = true };
if (IsRedirect(response.StatusCode)) if (IsRedirect(response.StatusCode))
{ {
@@ -370,7 +393,7 @@ namespace PrivaPub.Infrastructure.Http
return Refuse(negativeKey, url, "a body over the size limit", exchange, "too-large"); return Refuse(negativeKey, url, "a body over the size limit", exchange, "too-large");
exchange.Bytes = body.Length; exchange.Bytes = body.Length;
return new FetchedJson { FinalUri = target, Document = JsonDocument.Parse(body) }; return new FetchedJson { FinalUri = target, Document = JsonDocument.Parse(body), ETag = RawETag(response) };
} }
return Refuse(negativeKey, url, "too many redirects", exchange, "too-many-redirects"); return Refuse(negativeKey, url, "too many redirects", exchange, "too-many-redirects");
} }
@@ -658,6 +681,10 @@ namespace PrivaPub.Infrastructure.Http
return buffer.ToArray(); return buffer.ToArray();
} }
// as sent, since it is only ever sent back: NodeBB's carries no quotes, which the typed header rejects
static string RawETag(HttpResponseMessage response) =>
response.Headers.TryGetValues("ETag", out var values) ? values.FirstOrDefault() : default;
static bool IsRedirect(HttpStatusCode status) => static bool IsRedirect(HttpStatusCode status) =>
status is HttpStatusCode.MovedPermanently or HttpStatusCode.Found or HttpStatusCode.SeeOther status is HttpStatusCode.MovedPermanently or HttpStatusCode.Found or HttpStatusCode.SeeOther
or HttpStatusCode.TemporaryRedirect or HttpStatusCode.PermanentRedirect; or HttpStatusCode.TemporaryRedirect or HttpStatusCode.PermanentRedirect;
@@ -87,6 +87,8 @@ namespace PrivaPub.Middleware
.AddSingleton<IActivityHandler, DislikeHandler>() .AddSingleton<IActivityHandler, DislikeHandler>()
.AddSingleton<IActivityHandler, EmojiReactHandler>() .AddSingleton<IActivityHandler, EmojiReactHandler>()
.AddSingleton<IActivityHandler, QuoteRequestHandler>() .AddSingleton<IActivityHandler, QuoteRequestHandler>()
.AddSingleton<IActivityHandler, WarnHandler>()
.AddSingleton<IActivityHandler, ResolveHandler>()
.AddSingleton<IReactions, Reactions>() .AddSingleton<IReactions, Reactions>()
.AddSingleton<LinkPreviews>() .AddSingleton<LinkPreviews>()
.AddSingleton<ILinkPreviews>(services => services.GetRequiredService<LinkPreviews>()) .AddSingleton<ILinkPreviews>(services => services.GetRequiredService<LinkPreviews>())
@@ -118,6 +120,7 @@ namespace PrivaPub.Middleware
.AddSingleton<IJobHandler, PollRefreshJob>() .AddSingleton<IJobHandler, PollRefreshJob>()
.AddSingleton<IJobHandler, RecoveryJob>() .AddSingleton<IJobHandler, RecoveryJob>()
.AddSingleton<IJobHandler, Federation.Actors.AccountCountsJob>() .AddSingleton<IJobHandler, Federation.Actors.AccountCountsJob>()
.AddSingleton<IJobHandler, Domain.Social.FeedSyncJob>()
.AddSingleton<IJobHandler, Federation.Actors.OutboxBackfill>() .AddSingleton<IJobHandler, Federation.Actors.OutboxBackfill>()
.AddSingleton<IJobHandler, Federation.Actors.FollowingSynchronization>() .AddSingleton<IJobHandler, Federation.Actors.FollowingSynchronization>()
.AddSingleton<IJobHandler, PollCloseJob>() .AddSingleton<IJobHandler, PollCloseJob>()
@@ -134,6 +137,7 @@ namespace PrivaPub.Middleware
.AddSingleton<IJobHandler, DeliveryJobHandler>() .AddSingleton<IJobHandler, DeliveryJobHandler>()
.AddHostedService<JobWorker>() .AddHostedService<JobWorker>()
.AddHostedService<Domain.Social.FollowResender>() .AddHostedService<Domain.Social.FollowResender>()
.AddHostedService<Domain.Social.FeedReader>()
.AddSingleton<Federation.Relays.IRelays, Federation.Relays.Relays>() .AddSingleton<Federation.Relays.IRelays, Federation.Relays.Relays>()
.AddHostedService<Federation.Relays.RelaySubscriber>(); .AddHostedService<Federation.Relays.RelaySubscriber>();
} }
@@ -232,6 +236,7 @@ namespace PrivaPub.Middleware
.AddTransient<IRelationshipService, RelationshipService>() .AddTransient<IRelationshipService, RelationshipService>()
.AddTransient<IReportService, ReportService>() .AddTransient<IReportService, ReportService>()
.AddTransient<IFollowService, FollowService>() .AddTransient<IFollowService, FollowService>()
.AddTransient<IFeedFollows, FeedFollows>()
.AddTransient<ITimelineService, TimelineService>() .AddTransient<ITimelineService, TimelineService>()
.AddSingleton<AppConfigurationService>() .AddSingleton<AppConfigurationService>()
.AddHttpContextAccessor() .AddHttpContextAccessor()
+2 -1
View File
@@ -19,6 +19,7 @@ namespace PrivaPub.Models.Federation
Person, Person,
Group, Group,
Application, Application,
Reporter//the anonymous Service that carries reports to Lemmy (LocalActorService.ReporterUserName) Reporter,//the anonymous Service that carries reports to Lemmy (LocalActorService.ReporterUserName)
Reader//the anonymous Service that follows the communities of the feeds personas read (LocalActorService.ReaderUserName)
} }
} }
+12
View File
@@ -0,0 +1,12 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Federation
{
// the server's anonymous reader of feeds (LocalActorService.ReaderUserName), its keys
public class ReaderActor : Entity
{
public string PrivateKey { get; set; }
public string PublicKey { get; set; }
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
}
}
@@ -0,0 +1,13 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Federation
{
// a thread's collection on another server as we last read it whole: its ETag, sent back as If-None-Match so an unchanged
// thread costs its server one 304 (NodeBB's context carries a digest of its posts)
public class ThreadCollection : Entity
{
public string URI { get; set; }
public string ETag { get; set; }
public DateTime ReadAt { get; set; } = DateTime.UtcNow;//expires after Indexes.ThreadCollectionLifetime
}
}
+2 -1
View File
@@ -38,7 +38,8 @@ namespace PrivaPub.Models.Jobs
SynchronizeFollowing, SynchronizeFollowing,
ProcessMedia, ProcessMedia,
ExportArchive, ExportArchive,
ImportArchive ImportArchive,
SyncFeeds
} }
public enum JobState public enum JobState
+4
View File
@@ -46,6 +46,10 @@ namespace PrivaPub.Models.Post
public PostPlace Place { get; set; } public PostPlace Place { get; set; }
[BsonIgnoreIfNull] [BsonIgnoreIfNull]
public PostPoll Poll { get; set; } public PostPoll Poll { get; set; }
[BsonIgnoreIfNull]
public List<PostFlair> Flairs { get; set; }
[BsonIgnoreIfNull]
public List<string> SearchableBy { get; set; }//FEP-268d on the post itself: outranks its author's
public List<float> Location { get; set; } = new(); public List<float> Location { get; set; } = new();
public float RangeKm { get; set; } = 5.0f; public float RangeKm { get; set; } = 5.0f;
[BsonIgnoreIfNull] [BsonIgnoreIfNull]
+13
View File
@@ -38,6 +38,19 @@
public List<string> Manual { get; set; } = new(); public List<string> Manual { get; set; } = new();
} }
// a community's label on a post (Lemmy 1.0's CommunityPostTag, PieFed's lemmy:CommunityTag): one of the community's own
public class PostFlair
{
public string Id { get; set; }
public string Name { get; set; }
public string Slug { get; set; }
public string Description { get; set; }
public string Color { get; set; }//Lemmy's colour slot, color01 to color10
public string TextColor { get; set; }//PieFed's, as #rrggbb
public string BackgroundColor { get; set; }
public bool BlurImages { get; set; }
}
public class PostSource public class PostSource
{ {
public string Content { get; set; } public string Content { get; set; }
@@ -0,0 +1,14 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Social
{
// a persona reading a feed elsewhere (Lemmy's multi-community, PieFed's feed): the server's reader follows the feed's
// communities, never the persona (owner decision 2026-10-07)
public class FeedSubscription : Entity
{
public string AvatarId { get; set; }
public string FeedActorURI { get; set; }
public string FeedAccountId { get; set; }//ForeignAvatar.ID
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
}
+2 -1
View File
@@ -4,7 +4,8 @@ namespace PrivaPub.Models.Social
{ {
public class Following : Entity public class Following : Entity
{ {
public string AvatarId { get; set; }//the local follower public string AvatarId { get; set; }//the local follower: a persona, or the server's reader of feeds (FollowerKind)
public PrivaPub.Models.Federation.LocalActorKind? FollowerKind { get; set; }//null for a persona
public string TargetActorURI { get; set; } public string TargetActorURI { get; set; }
public string TargetAccountId { get; set; }//Avatar.ID, Group.ID or ForeignAvatar.ID public string TargetAccountId { get; set; }//Avatar.ID, Group.ID or ForeignAvatar.ID
public bool TargetIsLocal { get; set; } public bool TargetIsLocal { get; set; }
+3 -1
View File
@@ -12,6 +12,7 @@ namespace PrivaPub.Models.Social
public string DedupeKey { get; set; } public string DedupeKey { get; set; }
public string Emoji { get; set; }//a reaction's emoji public string Emoji { get; set; }//a reaction's emoji
public string EmojiURL { get; set; } public string EmojiURL { get; set; }
public string Text { get; set; }//a moderation warning's reason
public bool IsRead { get; set; } public bool IsRead { get; set; }
public bool Filtered { get; set; }//held back by the persona's notification policy, in a NotificationRequest public bool Filtered { get; set; }//held back by the persona's notification policy, in a NotificationRequest
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
@@ -27,6 +28,7 @@ namespace PrivaPub.Models.Social
Update, Update,
Poll, Poll,
Reaction, Reaction,
Quote Quote,
ModerationWarning
} }
} }
+2
View File
@@ -78,5 +78,7 @@ namespace PrivaPub.Models.Social
public string ResolvedBy { get; set; } public string ResolvedBy { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? ResolvedAt { get; set; } public DateTime? ResolvedAt { get; set; }
public DateTime? RemoteResolvedAt { get; set; }//the server holding the reported content says it dealt with it (Lemmy's Resolve{Flag})
public string RemoteResolvedBy { get; set; }
} }
} }
+6 -1
View File
@@ -77,6 +77,10 @@ namespace PrivaPub.Models.User
public string InboxURL { get; set; } public string InboxURL { get; set; }
public string OutboxURL { get; set; } public string OutboxURL { get; set; }
public string FeaturedURL { get; set; }//featured: the posts it pins public string FeaturedURL { get; set; }//featured: the posts it pins
public List<PrivaPub.Models.Post.PostFlair> Flairs { get; set; } = new();//a community's labels for its posts
public List<string> FeedCommunities { get; set; } = new();//a feed's communities, its `following` (Lemmy's multi-communities, PieFed's feeds)
public List<string> SearchableBy { get; set; }//FEP-268d, when the account states it: outranks IsIndexable
public string ModeratorsURL { get; set; }//a community's attributedTo (Lemmy, PieFed, Mbin): the collection of its moderators
public List<AssertionKey> AssertionKeys { get; set; } = new();//its Ed25519 keys (FEP-521a), which prove what it sends (FEP-8b32) public List<AssertionKey> AssertionKeys { get; set; } = new();//its Ed25519 keys (FEP-521a), which prove what it sends (FEP-8b32)
public string WallURL { get; set; }//sm:wall (FEP-400e): where others write to it, Smithereen's walls public string WallURL { get; set; }//sm:wall (FEP-400e): where others write to it, Smithereen's walls
public string MovedToURL { get; set; } public string MovedToURL { get; set; }
@@ -149,7 +153,8 @@ namespace PrivaPub.Models.User
Group, Group,
Organization, Organization,
Person, Person,
Service Service,
Feed
} }
public enum AvatarAccountState public enum AvatarAccountState
+2
View File
@@ -69,6 +69,8 @@ namespace PrivaPub.Services
await DB.Default.DeleteAsync<Models.Social.PersonaList>(l => l.AvatarId == avatar.ID); await DB.Default.DeleteAsync<Models.Social.PersonaList>(l => l.AvatarId == avatar.ID);
await DB.Default.DeleteAsync<Models.Social.PersonaFilter>(f => f.AvatarId == avatar.ID); await DB.Default.DeleteAsync<Models.Social.PersonaFilter>(f => f.AvatarId == avatar.ID);
await DB.Default.DeleteAsync<Models.Social.FollowedTag>(t => t.AvatarId == avatar.ID); await DB.Default.DeleteAsync<Models.Social.FollowedTag>(t => t.AvatarId == avatar.ID);
// (the reader of feeds stops following what nobody reads any more at its next round)
await DB.Default.DeleteAsync<Models.Social.FeedSubscription>(s => s.AvatarId == avatar.ID);
// nothing of it publishes later, and none of its files stays served: its posts' media, its pictures and // nothing of it publishes later, and none of its files stays served: its posts' media, its pictures and
// what its scheduled posts held // what its scheduled posts held
await DB.Default.DeleteAsync<Models.Social.ScheduledStatus>(s => s.AvatarId == avatar.ID); await DB.Default.DeleteAsync<Models.Social.ScheduledStatus>(s => s.AvatarId == avatar.ID);
+1
View File
@@ -34,6 +34,7 @@ namespace PrivaPub.StaticServices
public Find<Delivery> Deliveries { get { return DB.Default.Find<Delivery>(); } } public Find<Delivery> Deliveries { get { return DB.Default.Find<Delivery>(); } }
public Find<InstanceActor> InstanceActors { get { return DB.Default.Find<InstanceActor>(); } } public Find<InstanceActor> InstanceActors { get { return DB.Default.Find<InstanceActor>(); } }
public Find<ReporterActor> ReporterActors { get { return DB.Default.Find<ReporterActor>(); } } public Find<ReporterActor> ReporterActors { get { return DB.Default.Find<ReporterActor>(); } }
public Find<ReaderActor> ReaderActors { get { return DB.Default.Find<ReaderActor>(); } }
public Find<Following> Followings { get { return DB.Default.Find<Following>(); } } public Find<Following> Followings { get { return DB.Default.Find<Following>(); } }
public Find<TimelineEntry> TimelineEntries { get { return DB.Default.Find<TimelineEntry>(); } } public Find<TimelineEntry> TimelineEntries { get { return DB.Default.Find<TimelineEntry>(); } }
+30 -5
View File
@@ -96,7 +96,9 @@ Priorities, used throughout:
- `Move`. - `Move`.
- `QuoteRequest`, its `Accept`/`Reject` (with `result`), and `Delete{QuoteAuthorization}`. - `QuoteRequest`, its `Accept`/`Reject` (with `result`), and `Delete{QuoteAuthorization}`.
- `FeatureRequest`/`FeatureAuthorization` (4.6). - `FeatureRequest`/`FeatureAuthorization` (4.6).
- **Threads:** `context` is a dereferenceable collection of the thread (FEP-7888, threads started on 4.5+). - **Threads:** `context` is a dereferenceable collection of the thread (FEP-7888, threads started on 4.5+). It
answers `If-None-Match` with a 304 (a weak ETag over the body, 4.7.3), but the body embeds only the first page, so its
ETag stands for the thread only while the thread fits on that page.
- **Interaction counts:** `likes` and `shares` carry `totalItems`. - **Interaction counts:** `likes` and `shares` carry `totalItems`.
- **Quotes:** `quote`, plus `quoteUri` and `_misskey_quote`, `quoteAuthorization`, and `interactionPolicy.canQuote`. - **Quotes:** `quote`, plus `quoteUri` and `_misskey_quote`, `quoteAuthorization`, and `interactionPolicy.canQuote`.
- **Link previews:** from 4.7, a `{type: Link, href}` **attachment** names the link a card is made from (FEP-8967). - **Link previews:** from 4.7, a `{type: Link, href}` **attachment** names the link a card is made from (FEP-8967).
@@ -490,7 +492,14 @@ on a Page: pins live in `featured`, locks in `Lock`.
- **Private messages:** `ChatMessage` on 0.19; a single-recipient `Note` on 1.0. - **Private messages:** `ChatMessage` on 0.19; a single-recipient `Note` on 1.0.
- **Context:** 1.0's `context` collection is unpaged and **every comment's own context URL returns the whole post - **Context:** 1.0's `context` collection is unpaged and **every comment's own context URL returns the whole post
thread**. Group threads by the root post, never by comparing `context` strings. thread**. Group threads by the root post, never by comparing `context` strings.
- **Feeds:** multi-communities are 1.0's `type: Feed` actors. - **Feeds:** multi-communities are 1.0's `type: Feed` actors at `/m/<name>`, with no WebFinger handle: the instance's
shared inbox and the **instance's key** (`owner` is the instance, not the feed), text in `description` (not `summary`),
the creator in `attributedTo`, and `following`, an unpaged `Collection` of the community ids with `totalItems`.
Following one stays on the follower's server, which subscribes to each community itself (checked 2026-10-07 on
1.0.0-beta.2). PrivaPub does the same through its reader of feeds, the `Service` `privapub_feeds`: Lemmy 1.0 takes its
`Follow` and answers `Accept` about 30 s later, and announces the community's threads to it; the feed's thread and the
persona's own posts in a community of ours the feed holds show in the persona's feed timeline (checked live
2026-10-08).
**Expects** **Expects**
- **Fetched documents:** Content-Type exactly one of `activity+json`, `activity+json; charset=utf-8`, or `ld+json` with - **Fetched documents:** Content-Type exactly one of `activity+json`, `activity+json; charset=utf-8`, or `ld+json` with
@@ -593,8 +602,15 @@ What it showed:
- **polls in communities** (`Question` with `votersCount`), and Mobilizon-style `Event`s; - **polls in communities** (`Question` with `votersCount`), and Mobilizon-style `Event`s;
- flairs in two dialects; - flairs in two dialects;
- comments: `repliesEnabled` (comment lock), `answer`, plus custom `ChooseAnswer` and `PollVote` activities; - comments: `repliesEnabled` (comment lock), `answer`, plus custom `ChooseAnswer` and `PollVote` activities;
- `Move{object: post}` between communities; - `Move{object: post, origin, target}` between communities, by the post's author or a moderator of the origin, relayed
- `Feed` actors; as the origin community's `Announce` (only from the web UI; its API has no move);
- `Feed` actors at `/f/<name>` with their own key, inbox, `followers`, `moderators` (the owner) and `following` (the
communities, a `Collection` with `totalItems`); PieFed's handle is `~name@host`. Following one is a `Follow` to the
feed, after which its owner's server sends `Add`/`Remove` of communities, signed by the feed; each member also
subscribes to every community as themselves (`feed_auto_follow`). Its API creates feeds (`POST /api/alpha/feed`).
PrivaPub follows a feed through its reader of feeds instead (owner decision 2026-10-07), sending the feed nothing:
PieFed takes the reader's `Follow` of each community at once and its threads reach the feed timeline, a moved
thread included (checked live 2026-10-08);
- emoji Likes and `EmojiReact` count as upvotes. - emoji Likes and `EmojiReact` count as upvotes.
- **How PieFed formats what it sends us:** - **How PieFed formats what it sends us:**
- It **chooses the format by our NodeInfo software name**, so keep that accurate. - It **chooses the format by our NodeInfo software name**, so keep that accurate.
@@ -659,7 +675,9 @@ What it showed:
- **NodeBB:** - **NodeBB:**
- A topic's first post is an `Article` with `name`, **`summary` = an excerpt** and `preview`; replies are Notes. - A topic's first post is an `Article` with `name`, **`summary` = an excerpt** and `preview`; replies are Notes.
- Categories are `Group`s **without `followers`**. - Categories are `Group`s **without `followers`**.
- `context` is a paged collection with an **ETag digest**; NodeBB refetches with `If-None-Match`. - `context` is a collection with an **ETag digest** of its posts, sent **without quotes** (not an RFC 9110
entity-tag: .NET's typed header drops it, so PrivaPub keeps the raw value); it carries `totalItems` and its items
inline, and answers `If-None-Match` with a 304. NodeBB refetches with it too.
- Since 4.15, an Announce of anything but a Create or a plain object is accepted only from Group actors. - Since 4.15, an Announce of anything but a Create or a plain object is accepted only from Group actors.
- It sends `Move`/`Remove` of a whole context (FEP-f15d) and `Add{post → context}` (FEP-11dd). - It sends `Move`/`Remove` of a whole context (FEP-f15d) and `Add{post → context}` (FEP-11dd).
- Chats are private Notes, threaded by `inReplyTo` into the room they answer. - Chats are private Notes, threaded by `inReplyTo` into the room they answer.
@@ -855,6 +873,8 @@ without a port, before it gives out its OAuth client.
- Its Mastodon API resolves an account elsewhere only when the search is not limited to a type - Its Mastodon API resolves an account elsewhere only when the search is not limited to a type
(`/api/v2/search?resolve=true`, no `type=accounts`); reactions go through Pleroma's route (`/api/v2/search?resolve=true`, no `type=accounts`); reactions go through Pleroma's route
(`PUT /api/v1/pleroma/statuses/:id/reactions/:emoji`). (`PUT /api/v1/pleroma/statuses/:id/reactions/:emoji`).
- **Threads:** a post's `context` is `/collections/conversations/<id>`, a collection whose first page lists the
thread's post ids (FEP-7888), with no ETag; no `contextHistory`.
- **Pasture evidence (2026-10-05, `tools/pasture/scenarios/mitra.sh`):** 28 checks pass, with no change to PrivaPub: - **Pasture evidence (2026-10-05, `tools/pasture/scenarios/mitra.sh`):** 28 checks pass, with no change to PrivaPub:
follows both ways, posts, replies both ways, likes and reposts both ways, an emoji reaction, a poll and alice's vote, follows both ways, posts, replies both ways, likes and reposts both ways, an emoji reaction, a poll and alice's vote,
edits and deletions both ways, direct messages both ways, the unfollow, statistics. edits and deletions both ways, direct messages both ways, the unfollow, statistics.
@@ -918,6 +938,11 @@ without a port, before it gives out its OAuth client.
- **Everything in a thread is added to its conversation** (FEP-171b): its edits come only as `Add{Update}`, under the - **Everything in a thread is added to its conversation** (FEP-171b): its edits come only as `Add{Update}`, under the
same activity id as the post's `Create`, and likes as `Add{Like}`. PrivaPub unwraps Create, Update and Delete, telling same activity id as the post's `Create`, and likes as `Add{Like}`. PrivaPub unwraps Create, Update and Delete, telling
an Update from the Create whose id it reuses by what it carries. an Update from the Create whose id it reuses by what it carries.
- **Its conversation container is not named by its posts:** `contextHistory` (and `context`) pointing at
`…/conversation/<uuid>` ride on the activities only; a Note's `context` is the thread's first post and it has no
`contextHistory`, though the FEP asks the first post for one. The container (public threads: unsigned GETs work)
lists the first post's plain `Create`, then an `Add{Create}` for every post. A post's `replies` lists every comment of
the thread, embedded, and is what PrivaPub reads to complete a Forte thread (checked live 2026-10-07).
- **Its own collections name themselves wrongly:** a channel's followers, following and outbox give an `id` with the - **Its own collections name themselves wrongly:** a channel's followers, following and outbox give an `id` with the
gateway path twice and a trailing `?`, which 404s, so their counts are not read. gateway path twice and a trailing `?`, which 404s, so their counts are not read.
- **Running it:** no image is published; `images/forte` builds its tag with composer on Hubzilla's PHP image, behind an - **Running it:** no image is published; `images/forte` builds its tag with composer on Hubzilla's PHP image, behind an
+28 -9
View File
@@ -221,6 +221,7 @@ and circles (see Owner decisions).
| Reactions and votes | **Public, with a one-time notice.** The client tells each persona once, before its first reaction or vote, that these are public and visible as that persona. | | Reactions and votes | **Public, with a one-time notice.** The client tells each persona once, before its first reaction or vote, that these are public and visible as that persona. |
| Who may quote a persona (default) | **Anyone, automatically**, as on Mastodon, for public and unlisted posts only. Each persona can change its default (followers only, or nobody) and each post can be changed; a granted quote can be revoked. Followers-only posts and DMs can never be quoted. | | Who may quote a persona (default) | **Anyone, automatically**, as on Mastodon, for public and unlisted posts only. Each persona can change its default (followers only, or nobody) and each post can be changed; a granted quote can be revoked. Followers-only posts and DMs can never be quoted. |
| Quote permission address | `/peasants/{name}/parrot-licences/{id}` | | Quote permission address | `/peasants/{name}/parrot-licences/{id}` |
| Following a feed (2026-10-07) | **Through the server.** When a persona follows a feed (Lemmy's multi-community, PieFed's feed), the server's own account subscribes to each community in it, as Lemmy does, so the communities and the feed's owner learn only that someone on this server reads them, never which persona. The persona reads the feed's posts in a feed timeline of its own. A persona that comments in one of those communities is still seen there as itself. |
### Owner decisions on statistics (2026-10-03) ### Owner decisions on statistics (2026-10-03)
@@ -300,7 +301,7 @@ restore system (JSON mostly, plus the files).
| Protection | **Plain archives, protected by file permissions** (`/var/lib/privapub/backups`, 2770, files 0640). No encryption. | | Protection | **Plain archives, protected by file permissions** (`/var/lib/privapub/backups`, 2770, files 0640). No encryption. |
| Consistency | **Production's mongod becomes a one-member replica set** (`rs0`), so a backup reads every collection at one instant. `setup.sh` converts it, which needs a planned restart of mongod. | | Consistency | **Production's mongod becomes a one-member replica set** (`rs0`), so a backup reads every collection at one instant. `setup.sh` converts it, which needs a planned restart of mongod. |
**Done 2026-10-07 (committed, not deployed):** **Done and deployed 2026-10-07 (v1.23.0, decePub v1.5.0):**
- media: files live exactly as long as something holds them (trash with a day's grace, janitor), finite focal points, - media: files live exactly as long as something holds them (trash with a day's grace, janitor), finite focal points,
pictures checked before they are decoded, audio and video bounded and processed off the request, a bounded media pictures checked before they are decoded, audio and video bounded and processed off the request, a bounded media
proxy, scheduled posts' media reserved, profile pictures as rows with `DELETE`, edits that reach the post, storage proxy, scheduled posts' media reserved, profile pictures as rows with `DELETE`, edits that reach the post, storage
@@ -310,9 +311,11 @@ restore system (JSON mostly, plus the files).
- a persona's archive, exported and imported (`tools/pasture/scenarios/persona-archive.sh` imports a real Mastodon - a persona's archive, exported and imported (`tools/pasture/scenarios/persona-archive.sh` imports a real Mastodon
archive: 156 posts, nothing delivered). archive: 156 posts, nothing delivered).
**At the next deploy, in this order:** `setup.sh` (the replica set, the backups directory, the runner in www-data's **Deployed 2026-10-07, in this order:** `setup.sh` (production's mongod is now the replica set `rs0`; the backups
group, the nginx locations), the deploy, `PrivaPub admin media audit` and, once its report is read, `--fix`; and the directory; the nginx locations), then v1.23.0, whose own pre-deploy backup was verified whole, then decePub v1.5.0.
owner's word on deleting the old pre-deploy dumps, which hold statistics salts. `PrivaPub admin media audit` found no media on production, so there was nothing to fix. The seven pre-deploy mongodumps,
which held statistics salts, were deleted (owner's go-ahead); a salt-free dump taken just before the replica-set
conversion is kept in `/var/backups/privapub.thepra.dev/`.
## Libraries (researched; no maintained .NET ActivityPub library exists, so Letterbook and Iceshrimp.NET both wrote their own) ## Libraries (researched; no maintained .NET ActivityPub library exists, so Letterbook and Iceshrimp.NET both wrote their own)
@@ -675,8 +678,11 @@ it, raw where it doesn't.
#### P7 Threads, communities and the social graph #### P7 Threads, communities and the social graph
- **Thread backfill:** - **Thread backfill:**
- read in order: `contextHistory`, then `context` (paged, with ETag), then `replies`. **Done (2026-10-05), but for - read in order: `contextHistory`, then `context` (paged, with ETag), then `replies`. **Done (2026-10-05; `contextHistory`
`contextHistory` and ETags:** a persona opening a public remote thread queues `FetchReplies` for the post and its and ETags 2026-10-07: a container's `Add`ed Creates and Updates are read for their posts; a whole read of a collection
that counts its posts or holds them all keeps its ETag, sent back as `If-None-Match`, a 304 checked live on Mastodon
4.7.3 and NodeBB 4.16, whose ETag has no quotes; neither Forte nor Mitra puts `contextHistory` on a post, and Forte's
`replies` already holds its whole thread):** a persona opening a public remote thread queues `FetchReplies` for the post and its
root, at most hourly each. The job reads the thread's FEP-7888 `context` collection (posts or, as FEP-f228 allows, root, at most hourly each. The job reads the thread's FEP-7888 `context` collection (posts or, as FEP-f228 allows,
the activities that made them), else the post's `replies` (PeerTube's `comments`) and theirs, two levels down. It the activities that made them), else the post's `replies` (PeerTube's `comments`) and theirs, two levels down. It
reads at most 5 pages and 100 posts per job, signed by the instance actor, and stores what it lacks through reads at most 5 pages and 100 posts per job, signed by the instance actor, and stores what it lacks through
@@ -686,8 +692,19 @@ it, raw where it doesn't.
public and unlisted replies only; Mastodon finds a thread's replies through them, checked live). public and unlisted replies only; Mastodon finds a thread's replies through them, checked live).
- **Lemmy, PieFed and Mbin:** - **Lemmy, PieFed and Mbin:**
- the moderation set: removals, locks, bans (**done 2026-10-05**, relayed by the community: a lock refuses replies, a - the moderation set: removals, locks, bans (**done 2026-10-05**, relayed by the community: a lock refuses replies, a
ban of a persona refuses its posts there and shows as `blocked_by`), featured, moderators, `Warn`, `Resolve`; ban of a persona refuses its posts there and shows as `blocked_by`), featured; `Warn` and `Resolve` (**done
- votes in and out; link posts; flairs; `Feed` actors; community polls; post `Move`; 2026-10-07**: a warning becomes the persona's `moderation_warning` notification, believed from the community's
server or its moderators collection, kept as `ForeignAvatar.ModeratorsURL`; a resolution is kept on our report);
- votes in and out (**done 2026-10-07**: a persona's downvote is a `Dislike`, one vote a post, a changed vote sent as
the new one alone; a vote on a community's post goes to the community, and to its author only on another server; Mbin keeps a favourite apart from a vote, so
there a favourite stays after a switch to a downvote); link posts; flairs (**done 2026-10-07**: Lemmy 1.0's `CommunityPostTag` and PieFed's
`lemmy:CommunityTag`, on posts and communities, described from the community's list, as `privapub.flairs`); `Feed`
actors (**read 2026-10-07**: Lemmy 1.0's and PieFed's feeds resolve as accounts whose `/following` is their
communities, kept from the feed's `following` once a day; **followed through the server since 2026-10-07** (owner
decision, see "Owner decisions on what PrivaPub reveals"): the Service `privapub_feeds` follows the communities of
every feed read here, reconciled when a persona follows or leaves one, when a feed's list is read again and every six
hours, and the persona reads `GET /api/v1/timelines/feed/:id`); community polls; post `Move` (**done 2026-10-07**: PieFed's, relayed by the community the post
leaves, moves the post and its thread; checked live);
- the outbound shape Lemmy requires; - the outbound shape Lemmy requires;
- communities we host announce to the author's own instance too: **done 2026-10-06** (its server's shared inbox, - communities we host announce to the author's own instance too: **done 2026-10-06** (its server's shared inbox,
whether anyone there follows the community or not: Lemmy keeps its user's post pending until it is announced back); whether anyone there follows the community or not: Lemmy keeps its user's post pending until it is announced back);
@@ -706,7 +723,9 @@ it, raw where it doesn't.
- FEP-8fcf followers sync: **done 2026-10-06** (owner decision; sent: the digest of a persona's followers on the - FEP-8fcf followers sync: **done 2026-10-06** (owner decision; sent: the digest of a persona's followers on the
receiving server, and a signed roll-call listing only them; honoured: a sender's digest of its followers here, receiving server, and a signed roll-call listing only them; honoured: a sender's digest of its followers here,
mended from its list; checked live against Mastodon, all four ways a follow can be lost); mended from its list; checked live against Mastodon, all four ways a follow can be lost);
- `indexable`/`discoverable`/`searchableBy`; - `indexable`/`discoverable`/`searchableBy`: **done 2026-10-07** (account search already left out undiscoverable
accounts; status search now reads FEP-268d `searchableBy` on remote accounts and on each post, which outranks
`indexable`: Public, the author's followers (the reader must follow), or nobody else; not emitted by us);
- edit history from `formerRepresentations`: **done 2026-10-06** (Pleroma's and Akkoma's earlier versions, for a post - edit history from `formerRepresentations`: **done 2026-10-06** (Pleroma's and Akkoma's earlier versions, for a post
met after its edits and for the edits missed in between); met after its edits and for the edits missed in between);
- PeerTube reply rules and `ApproveReply`: **done 2026-10-06** (FEP-5624's `canReply`: a reply waits for the author's - PeerTube reply rules and `ApproveReply`: **done 2026-10-06** (FEP-5624's `canReply`: a reply waits for the author's