Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
088c54ddf3 | ||
|
|
6fccb6fe35 | ||
|
|
3b3f1f6b93 | ||
|
|
62189bf11d | ||
|
|
fb4949b511 | ||
|
|
d4b1788a4f | ||
|
|
587997dd67 | ||
|
|
6fc2d001b2 |
No files matched your search
@@ -22,8 +22,12 @@ Its defining idea: **one private login owns several public personas.**
|
||||
- **`DmGroup` is a direct-message conversation.**
|
||||
- **`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
|
||||
takes none from an Application, and to PieFed and Mbin, and names nobody. Both are server actors
|
||||
(`LocalActor.IsServerActor`): never followed, mentioned or shown as accounts.
|
||||
takes none from an Application, and to PieFed and Mbin, and names nobody.
|
||||
- **`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:
|
||||
- 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`).
|
||||
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`,
|
||||
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
|
||||
one is the reader's choice.
|
||||
|
||||
|
||||
+29
-8
@@ -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
|
||||
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.
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
`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
|
||||
|
||||
@@ -185,6 +195,8 @@ Received:
|
||||
| `Like` | counted and notified, on posts the liker could see |
|
||||
| `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 |
|
||||
| `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) |
|
||||
| `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 |
|
||||
@@ -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 |
|
||||
|
||||
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
|
||||
persona's posts passed on to its followers. A deleted post answers 410 with a `Tombstone`.
|
||||
`Reject{Follow}`, `Like`, `Dislike`, `Announce` and their `Undo`, `Flag`, `Join` and `Leave` of a remote event, and the
|
||||
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`.
|
||||
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
|
||||
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,
|
||||
its replies are read from their servers, at most once an hour: the thread's `context` collection (FEP-7888) if it has
|
||||
one, else 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.
|
||||
its replies are read from their servers, at most once an hour: the thread's conversation container (FEP-171b's
|
||||
`contextHistory`: the posts its owner's `Add`s brought in) or its `context` collection (FEP-7888) if it has one, else
|
||||
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
|
||||
Mastodon's authorized fetch; the server's own actors (the instance actor `/peasants/privapub` and the reporter
|
||||
`/peasants/privapub_reports`) are the exception, since their keys are needed first.
|
||||
Mastodon's authorized fetch; the server's own actors (the instance actor `/peasants/privapub`, the reporter
|
||||
`/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.
|
||||
- **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
|
||||
|
||||
@@ -16,5 +16,7 @@ namespace PrivaPub.ClientModels.Admin
|
||||
public bool IsResolved { get; set; }
|
||||
public DateTime CreatedAt { 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> GetInstanceActor(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> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token) => 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// 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
|
||||
// (G-0006): a locked post takes no reply, a banned persona posts nothing there, until the Undo
|
||||
[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)));
|
||||
}
|
||||
|
||||
[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]
|
||||
public async Task A_threads_context_collection_brings_every_reply_at_once()
|
||||
{
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
// 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]
|
||||
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.Host;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Http
|
||||
{
|
||||
// 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")));
|
||||
}
|
||||
|
||||
// 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
|
||||
[Fact]
|
||||
public async Task A_handle_finds_its_account_and_neither_posts_nor_a_hashtag()
|
||||
|
||||
@@ -71,6 +71,8 @@ namespace PrivaPub.Tests.Support
|
||||
new LikeHandler(Db, Reactions),
|
||||
new EmojiReactHandler(Db, Reactions),
|
||||
new QuoteRequestHandler(Quotes),
|
||||
new WarnHandler(Db, Local, Remote),
|
||||
new ResolveHandler(Db, Local),
|
||||
new DislikeHandler(Db),
|
||||
new JoinHandler(Db, Local, Delivery),
|
||||
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote, Records, Quotes, relays: Relays),
|
||||
|
||||
@@ -21,6 +21,7 @@ namespace PrivaPub.Tests.Support
|
||||
readonly ConcurrentDictionary<string, (string Text, string ContentType)> _documents = new();
|
||||
readonly ConcurrentDictionary<string, (int Status, TimeSpan Delay)> _answers = new();
|
||||
readonly ConcurrentDictionary<string, (byte[] Bytes, string ContentType)> _files = new();
|
||||
readonly ConcurrentDictionary<string, string> _etags = new();
|
||||
|
||||
public int Port { get; }
|
||||
public string A => $"http://127.0.0.1:{Port}";
|
||||
@@ -64,6 +65,15 @@ namespace PrivaPub.Tests.Support
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
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;
|
||||
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");
|
||||
|
||||
// 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 ServeFile(string path, byte[] bytes, string contentType) => _files[path] = (bytes, contentType);
|
||||
|
||||
@@ -32,10 +32,12 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
readonly IFollowService _follows;
|
||||
readonly IOutboxPublisher _outbox;
|
||||
readonly IRelationshipService _relationships;
|
||||
readonly IFeedFollows _feeds;
|
||||
|
||||
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;
|
||||
_mapper = mapper;
|
||||
_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]
|
||||
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)
|
||||
return Json(Array.Empty<Account>());
|
||||
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());
|
||||
}
|
||||
|
||||
// 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")]
|
||||
public async Task<IActionResult> Follow(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
if (local == default && remote == default)
|
||||
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);
|
||||
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);
|
||||
if (local == default && remote == default)
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -427,6 +450,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
var (local, remote) = await Find(id, token);
|
||||
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 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 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);
|
||||
@@ -443,7 +467,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
Muting = mute != default && (mute.ExpiresAt == default || mute.ExpiresAt > DateTime.UtcNow),
|
||||
MutingNotifications = mute?.HideNotifications == true,
|
||||
DomainBlocking = domainBlocked,
|
||||
Following = following?.State == FollowState.Accepted,
|
||||
Following = following?.State == FollowState.Accepted || reading,
|
||||
Requested = following?.State == FollowState.Requested,
|
||||
ShowingReblogs = following?.ShowReblogs ?? false,
|
||||
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))
|
||||
.Select(p => p.ReblogOfPostId));
|
||||
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)
|
||||
.Concat((await _dbEntities.ForeignAvatars.Match(f => authors.Contains(f.ID) && f.IsIndexable).ExecuteAsync(token)).Select(f => f.ID))
|
||||
.ToHashSet();
|
||||
var indexable = (await _dbEntities.Avatars.Match(a => authors.Contains(a.ID) && a.Settings.IsIndexable).ExecuteAsync(token)).Select(a => a.ID).ToHashSet();
|
||||
// a remote author's searchableBy (FEP-268d) outranks its indexable, and a post's own outranks both
|
||||
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>();
|
||||
foreach (var post in candidates)
|
||||
{
|
||||
var author = post.AuthorAccountId ?? post.GroupUserId;
|
||||
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))
|
||||
found.Add(post);
|
||||
if (found.Count >= offset + limit)
|
||||
|
||||
@@ -333,6 +333,13 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
[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);
|
||||
|
||||
// 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")]
|
||||
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);
|
||||
}
|
||||
|
||||
// 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]
|
||||
public async Task<IActionResult> Tag(string hashtag, CancellationToken token)
|
||||
{
|
||||
@@ -217,7 +237,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
[NotificationType.Update] = "update",
|
||||
[NotificationType.Poll] = "poll",
|
||||
[NotificationType.Reaction] = "pleroma:emoji_reaction",
|
||||
[NotificationType.Quote] = "quote"
|
||||
[NotificationType.Quote] = "quote",
|
||||
[NotificationType.ModerationWarning] = "moderation_warning"
|
||||
};
|
||||
|
||||
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(),
|
||||
status_id = newest.Status?.Id,
|
||||
emoji = newest.Emoji,
|
||||
emoji_url = newest.EmojiUrl
|
||||
emoji_url = newest.EmojiUrl,
|
||||
moderation_warning = newest.ModerationWarning
|
||||
});
|
||||
}
|
||||
return new
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
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 PrivaPubVotes Votes { get; set; } = new();
|
||||
public List<PrivaPubFlair> Flairs { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PrivaPubSource
|
||||
@@ -163,10 +164,23 @@
|
||||
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 int Up { get; set; }
|
||||
public int Down { get; set; }
|
||||
public bool? Downvoted { get; set; }//the viewer's own downvote; null with no viewer
|
||||
}
|
||||
|
||||
public class QuoteEntity
|
||||
@@ -405,6 +419,18 @@
|
||||
public Status Status { get; set; }
|
||||
public string Emoji { 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
|
||||
|
||||
@@ -49,6 +49,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
? new List<PostEntity>()
|
||||
: 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 warned = notifications.Any(n => n.Type == NotificationType.ModerationWarning) && viewerId != default ? await Account(viewerId, token) : default;
|
||||
return notifications
|
||||
.Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty) && (n.PostId == default || statuses.ContainsKey(n.PostId)))
|
||||
.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
|
||||
GroupKey = Controllers.NotificationsController.GroupKey(n, Controllers.NotificationsController.DefaultGrouped),
|
||||
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}:",
|
||||
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();
|
||||
}
|
||||
@@ -209,6 +218,9 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
var favourited = viewerId == default
|
||||
? new HashSet<string>()
|
||||
: (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
|
||||
? new HashSet<string>()
|
||||
: (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),
|
||||
Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)),
|
||||
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(),
|
||||
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(),
|
||||
@@ -480,7 +492,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
List<CustomEmojiEntity> Emojis(IEnumerable<CustomEmoji> emojis) =>
|
||||
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),
|
||||
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
|
||||
},
|
||||
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()
|
||||
|
||||
@@ -31,7 +31,9 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
Forwarded = r.Forwarded,
|
||||
IsResolved = r.IsResolved,
|
||||
CreatedAt = r.CreatedAt,
|
||||
ResolvedAt = r.ResolvedAt
|
||||
ResolvedAt = r.ResolvedAt,
|
||||
RemoteResolvedAt = r.RemoteResolvedAt,
|
||||
RemoteResolvedBy = r.RemoteResolvedBy
|
||||
}));
|
||||
|
||||
[HttpPost, Route("/clientapi/moderator/reports/{id}/resolve")]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,7 @@ namespace PrivaPub.Domain.Social
|
||||
var following = new Following
|
||||
{
|
||||
AvatarId = follower.Id,
|
||||
FollowerKind = follower.Kind == LocalActorKind.Person ? default(LocalActorKind?) : follower.Kind,
|
||||
TargetActorURI = targetUri,
|
||||
TargetAccountId = local?.Id ?? remote.ID,
|
||||
TargetIsLocal = local != default,
|
||||
@@ -298,7 +299,7 @@ namespace PrivaPub.Domain.Social
|
||||
.ExecuteAsync(token);
|
||||
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;
|
||||
if (follower != default && !string.IsNullOrEmpty(inbox))
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace PrivaPub.Domain.Social
|
||||
public static class Notifications
|
||||
{
|
||||
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)
|
||||
return;
|
||||
@@ -28,6 +28,7 @@ namespace PrivaPub.Domain.Social
|
||||
PostId = postId,
|
||||
Emoji = emoji,
|
||||
EmojiURL = emojiUrl,
|
||||
Text = text,
|
||||
DedupeKey = emoji == default ? $"{type}|{avatarId}|{fromActorUri}|{postId}" : $"{type}|{avatarId}|{fromActorUri}|{postId}|{emoji}",
|
||||
Filtered = action == NotificationAction.Filter
|
||||
};
|
||||
|
||||
@@ -64,6 +64,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
Task<StatusOutcome> Edit(LocalActor author, string postId, StatusDraft draft, CancellationToken token);
|
||||
Task<StatusOutcome> Remove(LocalActor author, string postId, 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);
|
||||
}
|
||||
|
||||
@@ -438,6 +439,9 @@ namespace PrivaPub.Domain.Statuses
|
||||
var likePermission = on ? await Permission(post, me, InteractionKind.Like, token) : QuotePermission.Granted;
|
||||
if (likePermission == QuotePermission.Denied)
|
||||
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
|
||||
// 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--;
|
||||
}
|
||||
|
||||
if (post.IsFederatedCopy && await AuthorInbox(post, token) is { } inbox)
|
||||
if (post.IsFederatedCopy && await VoteInboxes(post, token) is { Count: > 0 } inboxes)
|
||||
{
|
||||
var like = new JsonObject
|
||||
{
|
||||
@@ -485,11 +489,95 @@ namespace PrivaPub.Domain.Statuses
|
||||
if (on && favourite.Approval == ApprovalState.Pending)
|
||||
await _approvals.Ask(me, post, InteractionKind.Like, like, favourite.ID, token);
|
||||
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);
|
||||
}
|
||||
|
||||
// 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
|
||||
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;
|
||||
|
||||
@@ -40,13 +40,14 @@ namespace PrivaPub.Domain.Timelines
|
||||
{
|
||||
case PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly:
|
||||
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);
|
||||
foreach (var following in followers)
|
||||
if (await Shows(following, post, token))
|
||||
recipients.Add(following.AvatarId);
|
||||
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);
|
||||
// a post on a wall (FEP-400e): its owner, when a persona, and those following the owner
|
||||
if (!string.IsNullOrEmpty(post.WallOwnerURI))
|
||||
|
||||
@@ -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
|
||||
// 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
|
||||
// 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
|
||||
{
|
||||
readonly IRemoteActorService _remoteActors;
|
||||
readonly DbEntities _dbEntities;
|
||||
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;
|
||||
_dbEntities = dbEntities;
|
||||
_featured = featured;
|
||||
_localActors = localActors;
|
||||
_jobs = jobs;
|
||||
}
|
||||
|
||||
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);
|
||||
if (actor == default)
|
||||
return JobOutcome.Done;
|
||||
await DB.Default.Update<ForeignAvatar>().MatchID(actor.ID)
|
||||
.Modify(a => a.FollowersCount, await Total(actor, actor.FollowersURL, token))
|
||||
.Modify(a => a.FollowingCount, await Total(actor, actor.FollowingURL, token))
|
||||
.Modify(a => a.StatusesCount, await Total(actor, actor.OutboxURL, token))
|
||||
.Modify(a => a.CountedAt, DateTime.UtcNow)
|
||||
.ExecuteAsync(token);
|
||||
var feed = actor.AvatarType == AvatarType.Feed;
|
||||
var (following, communities, listed) = await Read(actor, actor.FollowingURL, feed, token);
|
||||
var update = DB.Default.Update<ForeignAvatar>().MatchID(actor.ID)
|
||||
.Modify(a => a.FollowersCount, (await Read(actor, actor.FollowersURL, false, token)).Total)
|
||||
.Modify(a => a.FollowingCount, following)
|
||||
.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)
|
||||
await _featured.Sync(actor, token);
|
||||
return JobOutcome.Done;
|
||||
}
|
||||
|
||||
// only from the actor's own server, as everything we believe about it
|
||||
async Task<int?> Total(ForeignAvatar actor, string collection, CancellationToken token)
|
||||
// only from the actor's own server, as everything we believe about it: its total, and the ids it lists when asked
|
||||
// (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))
|
||||
return default;
|
||||
return (default, listed, false);
|
||||
using var scope = HttpScope.For("collection");
|
||||
using var fetched = await _remoteActors.FetchObject(collection, token);
|
||||
return fetched != default && fetched.Root.ValueKind == System.Text.Json.JsonValueKind.Object
|
||||
&& fetched.Root.TryGetProperty("totalItems", out var total) && total.TryGetInt32(out var count) && count >= 0
|
||||
? count
|
||||
: default;
|
||||
if (fetched == default || fetched.Root.ValueKind != System.Text.Json.JsonValueKind.Object)
|
||||
return (default, listed, false);
|
||||
int? total = fetched.Root.TryGetProperty("totalItems", out var counted) && counted.TryGetInt32(out var count) && count >= 0 ? count : 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ namespace PrivaPub.Federation.Actors
|
||||
|
||||
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 Type { get; init; }
|
||||
@@ -36,6 +36,9 @@ namespace PrivaPub.Federation.Actors
|
||||
public string IconDescription { get; init; }
|
||||
public string HeaderDescription { 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 DateTime? Published { get; init; }
|
||||
public List<CustomEmoji> Emojis { get; init; } = new();
|
||||
@@ -71,7 +74,8 @@ namespace PrivaPub.Federation.Actors
|
||||
Features = ActorFeatures.Detect(root),
|
||||
PreferredUsername = RemoteActorService.Text(root, "preferredUsername"),
|
||||
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,
|
||||
Inbox = Origin.Same(inbox, id) ? inbox : default,
|
||||
Outbox = RemoteActorService.Text(root, "outbox"),
|
||||
@@ -92,6 +96,10 @@ namespace PrivaPub.Federation.Actors
|
||||
IconDescription = root.TryGetProperty("icon", out var described) ? Alt(described) : default,
|
||||
HeaderDescription = root.TryGetProperty("image", out var headerImage) ? Alt(headerImage) : default,
|
||||
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(),
|
||||
Published = DateTimeOffset.TryParse(RemoteActorService.Text(root, "published"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var published)
|
||||
? published.UtcDateTime
|
||||
|
||||
@@ -51,7 +51,7 @@ namespace PrivaPub.Federation.Actors
|
||||
public string Wall => $"{Uri}/graffiti";
|
||||
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
|
||||
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 Wardens => $"{Uri}/wardens";
|
||||
public string SharedInbox => $"{BaseAddress}/human-centipede";
|
||||
@@ -74,6 +74,7 @@ namespace PrivaPub.Federation.Actors
|
||||
Task<LocalActor> FindByAddress(string address, CancellationToken token);
|
||||
Task<LocalActor> GetInstanceActor(CancellationToken token);
|
||||
Task<LocalActor> GetReporterActor(CancellationToken token);
|
||||
Task<LocalActor> GetReaderActor(CancellationToken token);
|
||||
Task<bool> IsUserNameTaken(string userName, CancellationToken token);
|
||||
Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token);
|
||||
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
|
||||
// (owner decision 2026-10-06): one actor for the server, never one per persona
|
||||
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) =>
|
||||
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)
|
||||
{
|
||||
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"
|
||||
};
|
||||
|
||||
@@ -103,6 +108,7 @@ namespace PrivaPub.Federation.Actors
|
||||
readonly IOptionsMonitor<AppConfiguration> _appConfiguration;
|
||||
InstanceActor _instanceActor;
|
||||
ReporterActor _reporterActor;
|
||||
ReaderActor _readerActor;
|
||||
|
||||
public LocalActorService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration)
|
||||
{
|
||||
@@ -121,6 +127,8 @@ namespace PrivaPub.Federation.Actors
|
||||
return await GetInstanceActor(token);
|
||||
if (userName == ReporterUserName)
|
||||
return await GetReporterActor(token);
|
||||
if (userName == ReaderUserName)
|
||||
return await GetReaderActor(token);
|
||||
|
||||
var avatar = await _dbEntities.Avatars
|
||||
.Match(a => a.UserName == userName && !a.DeletionAt.HasValue)
|
||||
@@ -158,6 +166,8 @@ namespace PrivaPub.Federation.Actors
|
||||
return group == default ? default : FromGroup(group);
|
||||
case LocalActorKind.Reporter:
|
||||
return await GetReporterActor(token);
|
||||
case LocalActorKind.Reader:
|
||||
return await GetReaderActor(token);
|
||||
default:
|
||||
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)
|
||||
{
|
||||
var reporter = await _dbEntities.ReporterActors.Sort(r => r.CreationDate, Order.Ascending).ExecuteFirstAsync(token);
|
||||
|
||||
@@ -17,6 +17,8 @@ namespace PrivaPub.Federation.Actors
|
||||
public interface IRemoteActorService
|
||||
{
|
||||
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> GetActorByKeyId(string keyId, bool refresh, CancellationToken token);
|
||||
bool KeyTemporarilyUnavailable(string keyId) => false;
|
||||
@@ -50,13 +52,17 @@ namespace PrivaPub.Federation.Actors
|
||||
_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");
|
||||
var signer = await _localActors.GetInstanceActor(token);
|
||||
var fetched = await Get(uri, signer, token);
|
||||
if (fetched == default)
|
||||
return default;
|
||||
var fetched = string.IsNullOrEmpty(etag)
|
||||
? await Get(uri, signer, token)
|
||||
: 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");
|
||||
if (Origin.IsDocumentAt(id, fetched.FinalUri))
|
||||
@@ -234,6 +240,9 @@ namespace PrivaPub.Federation.Actors
|
||||
.Modify(a => a.IsLocked, actor.Locked)
|
||||
.Modify(a => a.IsMemorial, actor.Memorial)
|
||||
.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.Published, actor.Published)
|
||||
.Modify(a => a.Emojis, actor.Emojis)
|
||||
|
||||
@@ -532,8 +532,8 @@ namespace PrivaPub.Federation.Controllers
|
||||
if (HttpMethods.IsGet(Request.Method))
|
||||
Response.Headers.Vary = "Accept";
|
||||
// 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
|
||||
// the public pages
|
||||
// instance actor, the reporter, the reader of feeds), whose keys peers need first, and except for browsers, which
|
||||
// only get redirected to the public pages
|
||||
if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method) && !WantsHtml() && Request.Path.Value != "/"
|
||||
&& !LocalActorService.IsServerActorName(context.RouteData.Values["actor"] as string)
|
||||
&& await _fetches.Requester(Request, HttpContext.RequestAborted) == default)
|
||||
|
||||
@@ -67,6 +67,7 @@ namespace PrivaPub.Federation.Inbox
|
||||
LocalActorKind.Group when !local.IsCircle => "group",
|
||||
LocalActorKind.Application => "application",
|
||||
LocalActorKind.Reporter => "reporter",
|
||||
LocalActorKind.Reader => "reader",
|
||||
_ => default
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using static PrivaPub.Federation.Objects.ActivityJson;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Federation.Inbox
|
||||
@@ -20,5 +27,23 @@ namespace PrivaPub.Federation.Inbox
|
||||
}
|
||||
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)
|
||||
{
|
||||
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);
|
||||
return;
|
||||
@@ -242,6 +242,9 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
case "Lock" when objectUri != default:
|
||||
await Lock(objectUri, group, true, token);
|
||||
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":
|
||||
await Lock(Id(inner["object"]?["object"]), group, false, token);
|
||||
break;
|
||||
@@ -305,6 +308,42 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
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
|
||||
// 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.
|
||||
|
||||
@@ -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>()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,8 @@ namespace PrivaPub.Federation.Inbox
|
||||
post.Audio = note.Audio ?? post.Audio;
|
||||
post.Event = note.Event ?? post.Event;
|
||||
post.Place = note.Place;
|
||||
post.Flairs = await RemotePosts.Flairs(note, post.AudienceURI, token);
|
||||
post.SearchableBy = note.SearchableBy;
|
||||
if (note.CommentsEnabled is { } enabled)
|
||||
post.LockedAt = enabled ? null : post.LockedAt ?? DateTime.UtcNow;
|
||||
if (!IsEdit(note, post))
|
||||
|
||||
@@ -106,6 +106,7 @@ namespace PrivaPub.Federation.Inbox
|
||||
Excerpt = note.Excerpt,
|
||||
Source = note.Source,
|
||||
Poll = note.Poll,
|
||||
SearchableBy = note.SearchableBy,
|
||||
QuotePolicy = note.QuotePolicy,
|
||||
ReplyPolicy = note.ReplyPolicy,
|
||||
LikePolicy = note.LikePolicy,
|
||||
@@ -137,10 +138,20 @@ namespace PrivaPub.Federation.Inbox
|
||||
EditedAt = note.Updated,
|
||||
Revisions = note.FormerRepresentations.ToList()
|
||||
};
|
||||
post.Flairs = await Flairs(note, post.AudienceURI, token);
|
||||
await Episode(post, note, author, token);
|
||||
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);
|
||||
|
||||
// a post that is only a link to a podcast episode of its own author (Castopod announces each episode so): the
|
||||
|
||||
@@ -20,9 +20,11 @@ namespace PrivaPub.Federation.Inbox
|
||||
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
|
||||
// than what happened to reach us. The thread's own collection comes first (FEP-7888's `context`, which Mastodon 4.5+
|
||||
// serves with every reply at any depth); otherwise the post's `replies` (PeerTube's `comments`), and the replies'
|
||||
// own, two levels down. Read with the instance actor's signature, never a persona's, a few pages and at most
|
||||
// than what happened to reach us. The thread's own collections come first: its conversation container (FEP-171b's
|
||||
// `contextHistory`, every activity its owner added to the thread), then its posts (FEP-7888's `context`, which
|
||||
// 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
|
||||
// (RemotePosts.StoreContext: public and unlisted only, its author checked against its origin).
|
||||
public class RepliesJobHandler : IJobHandler
|
||||
@@ -74,14 +76,35 @@ namespace PrivaPub.Federation.Inbox
|
||||
if (await Document(post, token) is not { } document)
|
||||
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;
|
||||
}
|
||||
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;
|
||||
var answers = await StoreAll(replies, token);
|
||||
var (answers, _) = await StoreAll(replies, false, token);
|
||||
if (payload.Depth < MaxDepth)
|
||||
await _queue.EnqueueMany(answers.Where(a => a.IsFederatedCopy && a.AnsweringToPostId == post.ID).Take(MaxBranches)
|
||||
.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;
|
||||
}
|
||||
|
||||
// a collection as its server serves it now: what a post carries is read again by its id, since an 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 collection,
|
||||
// gives nothing
|
||||
async Task<JsonObject> Collection(JsonNode node, CancellationToken token)
|
||||
// a collection as its server serves it now, and its ETag: what a post carries is read again by its id, since an
|
||||
// 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
|
||||
// collection, gives nothing, and one whose server still has the version `etag` names is unchanged
|
||||
async Task<(JsonObject Collection, string ETag, bool Unchanged)> Collection(JsonNode node, string etag, CancellationToken token)
|
||||
{
|
||||
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))
|
||||
{
|
||||
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());
|
||||
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
|
||||
async Task<List<PostEntity>> StoreAll(JsonObject collection, CancellationToken token)
|
||||
// every post the collection lists, stored when we lack it: its pages in order, at most MaxPages and MaxItems; whether
|
||||
// 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 seen = 0;
|
||||
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))
|
||||
{
|
||||
if (++seen > MaxItems)
|
||||
break;
|
||||
// a context may list the activities that made the thread (FEP-f228) rather than its posts
|
||||
var id = Value(item, "type") is "Create" or "Update" ? Id(item["object"]) : Id(item);
|
||||
return (posts, false);
|
||||
var id = activities ? await Added(item, token) : PostId(item);
|
||||
if (id == default)
|
||||
continue;
|
||||
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;
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -40,6 +40,8 @@ namespace PrivaPub.Federation.Objects
|
||||
public IReadOnlyList<PostMedia> Attachments { get; init; } = Array.Empty<PostMedia>();
|
||||
public PostSource Source { 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 ReplyPolicy { get; init; }
|
||||
public InteractionRule LikePolicy { get; init; }
|
||||
@@ -119,6 +121,8 @@ namespace PrivaPub.Federation.Objects
|
||||
Attachments = Attachments(note),
|
||||
Source = ObjectShapes.Source(note),
|
||||
Poll = ObjectShapes.Poll(note),
|
||||
Flairs = ObjectShapes.Flairs(note["tag"]),
|
||||
SearchableBy = ObjectShapes.SearchableBy(note["searchableBy"]),
|
||||
QuotePolicy = ObjectShapes.QuotePolicy(note),
|
||||
ReplyPolicy = ObjectShapes.Policy(note, "canReply"),
|
||||
LikePolicy = ObjectShapes.Policy(note, "canLike"),
|
||||
|
||||
@@ -98,6 +98,60 @@ namespace PrivaPub.Federation.Objects
|
||||
.Take(MaxEmojis)
|
||||
.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");
|
||||
|
||||
// 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+\-.@]+$")]
|
||||
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("<[^>]*>")]
|
||||
private static partial Regex Tags();
|
||||
|
||||
|
||||
@@ -182,6 +182,7 @@ namespace PrivaPub.Federation.Outbox
|
||||
{ Kind: LocalActorKind.Person } => "person",
|
||||
{ Kind: LocalActorKind.Group } => "group",
|
||||
{ Kind: LocalActorKind.Reporter } => "reporter",
|
||||
{ Kind: LocalActorKind.Reader } => "reader",
|
||||
_ => "application"
|
||||
},
|
||||
Signature = "cavage:rsa-sha256"
|
||||
|
||||
@@ -126,7 +126,7 @@ namespace PrivaPub.Federation.Rendering
|
||||
{
|
||||
LocalActorKind.Group => "Group",
|
||||
LocalActorKind.Application => "Application",
|
||||
LocalActorKind.Reporter => "Service",
|
||||
LocalActorKind.Reporter or LocalActorKind.Reader => "Service",
|
||||
_ when actor.IsBot => "Service",
|
||||
_ => "Person"
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ namespace PrivaPub.Infrastructure.Data
|
||||
public static class Indexes
|
||||
{
|
||||
public static readonly TimeSpan TombstoneLifetime = TimeSpan.FromDays(90);
|
||||
public static readonly TimeSpan ThreadCollectionLifetime = TimeSpan.FromDays(30);
|
||||
|
||||
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.ConversationId, p => p.ID);
|
||||
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
|
||||
// (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);
|
||||
@@ -166,6 +168,11 @@ namespace PrivaPub.Infrastructure.Data
|
||||
await Plain<ObjectRecord>(token, r => r.PostId);
|
||||
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<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 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)
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ namespace PrivaPub.Infrastructure.Http
|
||||
public Uri FinalUri { get; init; }
|
||||
public JsonDocument Document { get; init; }
|
||||
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();
|
||||
}
|
||||
@@ -24,6 +27,9 @@ namespace PrivaPub.Infrastructure.Http
|
||||
bool IsAllowed(Uri target);
|
||||
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);
|
||||
/// <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);
|
||||
Task<(Uri FinalUri, string Html)> GetPage(string url, 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");
|
||||
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
|
||||
{
|
||||
@@ -308,7 +327,7 @@ namespace PrivaPub.Infrastructure.Http
|
||||
var exchange = new Exchange(url, HttpScope.Purpose ?? "object");
|
||||
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);
|
||||
}
|
||||
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))
|
||||
{
|
||||
@@ -338,11 +357,15 @@ namespace PrivaPub.Infrastructure.Http
|
||||
for (var hop = 0; hop <= MaxRedirects; hop++)
|
||||
{
|
||||
using var request = Request(target, accept);
|
||||
if (!string.IsNullOrEmpty(etag))
|
||||
request.Headers.TryAddWithoutValidation("If-None-Match", etag);
|
||||
sign?.Invoke(request);
|
||||
|
||||
using var response = await _httpClientFactory.CreateClient(ClientName)
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
|
||||
exchange.Answered(response);
|
||||
if (response.StatusCode == HttpStatusCode.NotModified && !string.IsNullOrEmpty(etag))
|
||||
return new FetchedJson { FinalUri = target, ETag = etag, NotModified = true };
|
||||
|
||||
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");
|
||||
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");
|
||||
}
|
||||
@@ -658,6 +681,10 @@ namespace PrivaPub.Infrastructure.Http
|
||||
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) =>
|
||||
status is HttpStatusCode.MovedPermanently or HttpStatusCode.Found or HttpStatusCode.SeeOther
|
||||
or HttpStatusCode.TemporaryRedirect or HttpStatusCode.PermanentRedirect;
|
||||
|
||||
@@ -87,6 +87,8 @@ namespace PrivaPub.Middleware
|
||||
.AddSingleton<IActivityHandler, DislikeHandler>()
|
||||
.AddSingleton<IActivityHandler, EmojiReactHandler>()
|
||||
.AddSingleton<IActivityHandler, QuoteRequestHandler>()
|
||||
.AddSingleton<IActivityHandler, WarnHandler>()
|
||||
.AddSingleton<IActivityHandler, ResolveHandler>()
|
||||
.AddSingleton<IReactions, Reactions>()
|
||||
.AddSingleton<LinkPreviews>()
|
||||
.AddSingleton<ILinkPreviews>(services => services.GetRequiredService<LinkPreviews>())
|
||||
@@ -118,6 +120,7 @@ namespace PrivaPub.Middleware
|
||||
.AddSingleton<IJobHandler, PollRefreshJob>()
|
||||
.AddSingleton<IJobHandler, RecoveryJob>()
|
||||
.AddSingleton<IJobHandler, Federation.Actors.AccountCountsJob>()
|
||||
.AddSingleton<IJobHandler, Domain.Social.FeedSyncJob>()
|
||||
.AddSingleton<IJobHandler, Federation.Actors.OutboxBackfill>()
|
||||
.AddSingleton<IJobHandler, Federation.Actors.FollowingSynchronization>()
|
||||
.AddSingleton<IJobHandler, PollCloseJob>()
|
||||
@@ -134,6 +137,7 @@ namespace PrivaPub.Middleware
|
||||
.AddSingleton<IJobHandler, DeliveryJobHandler>()
|
||||
.AddHostedService<JobWorker>()
|
||||
.AddHostedService<Domain.Social.FollowResender>()
|
||||
.AddHostedService<Domain.Social.FeedReader>()
|
||||
.AddSingleton<Federation.Relays.IRelays, Federation.Relays.Relays>()
|
||||
.AddHostedService<Federation.Relays.RelaySubscriber>();
|
||||
}
|
||||
@@ -232,6 +236,7 @@ namespace PrivaPub.Middleware
|
||||
.AddTransient<IRelationshipService, RelationshipService>()
|
||||
.AddTransient<IReportService, ReportService>()
|
||||
.AddTransient<IFollowService, FollowService>()
|
||||
.AddTransient<IFeedFollows, FeedFollows>()
|
||||
.AddTransient<ITimelineService, TimelineService>()
|
||||
.AddSingleton<AppConfigurationService>()
|
||||
.AddHttpContextAccessor()
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace PrivaPub.Models.Federation
|
||||
Person,
|
||||
Group,
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,8 @@ namespace PrivaPub.Models.Jobs
|
||||
SynchronizeFollowing,
|
||||
ProcessMedia,
|
||||
ExportArchive,
|
||||
ImportArchive
|
||||
ImportArchive,
|
||||
SyncFeeds
|
||||
}
|
||||
|
||||
public enum JobState
|
||||
|
||||
@@ -46,6 +46,10 @@ namespace PrivaPub.Models.Post
|
||||
public PostPlace Place { get; set; }
|
||||
[BsonIgnoreIfNull]
|
||||
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 float RangeKm { get; set; } = 5.0f;
|
||||
[BsonIgnoreIfNull]
|
||||
|
||||
@@ -38,6 +38,19 @@
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@ namespace PrivaPub.Models.Social
|
||||
{
|
||||
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 TargetAccountId { get; set; }//Avatar.ID, Group.ID or ForeignAvatar.ID
|
||||
public bool TargetIsLocal { get; set; }
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace PrivaPub.Models.Social
|
||||
public string DedupeKey { get; set; }
|
||||
public string Emoji { get; set; }//a reaction's emoji
|
||||
public string EmojiURL { get; set; }
|
||||
public string Text { get; set; }//a moderation warning's reason
|
||||
public bool IsRead { get; set; }
|
||||
public bool Filtered { get; set; }//held back by the persona's notification policy, in a NotificationRequest
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
@@ -27,6 +28,7 @@ namespace PrivaPub.Models.Social
|
||||
Update,
|
||||
Poll,
|
||||
Reaction,
|
||||
Quote
|
||||
Quote,
|
||||
ModerationWarning
|
||||
}
|
||||
}
|
||||
@@ -78,5 +78,7 @@ namespace PrivaPub.Models.Social
|
||||
public string ResolvedBy { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,10 @@ namespace PrivaPub.Models.User
|
||||
public string InboxURL { get; set; }
|
||||
public string OutboxURL { get; set; }
|
||||
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 string WallURL { get; set; }//sm:wall (FEP-400e): where others write to it, Smithereen's walls
|
||||
public string MovedToURL { get; set; }
|
||||
@@ -149,7 +153,8 @@ namespace PrivaPub.Models.User
|
||||
Group,
|
||||
Organization,
|
||||
Person,
|
||||
Service
|
||||
Service,
|
||||
Feed
|
||||
}
|
||||
|
||||
public enum AvatarAccountState
|
||||
|
||||
@@ -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.PersonaFilter>(f => f.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
|
||||
// what its scheduled posts held
|
||||
await DB.Default.DeleteAsync<Models.Social.ScheduledStatus>(s => s.AvatarId == avatar.ID);
|
||||
|
||||
@@ -34,6 +34,7 @@ namespace PrivaPub.StaticServices
|
||||
public Find<Delivery> Deliveries { get { return DB.Default.Find<Delivery>(); } }
|
||||
public Find<InstanceActor> InstanceActors { get { return DB.Default.Find<InstanceActor>(); } }
|
||||
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<TimelineEntry> TimelineEntries { get { return DB.Default.Find<TimelineEntry>(); } }
|
||||
|
||||
+30
-5
@@ -96,7 +96,9 @@ Priorities, used throughout:
|
||||
- `Move`.
|
||||
- `QuoteRequest`, its `Accept`/`Reject` (with `result`), and `Delete{QuoteAuthorization}`.
|
||||
- `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`.
|
||||
- **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).
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **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**
|
||||
- **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;
|
||||
- flairs in two dialects;
|
||||
- comments: `repliesEnabled` (comment lock), `answer`, plus custom `ChooseAnswer` and `PollVote` activities;
|
||||
- `Move{object: post}` between communities;
|
||||
- `Feed` actors;
|
||||
- `Move{object: post, origin, target}` between communities, by the post's author or a moderator of the origin, relayed
|
||||
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.
|
||||
- **How PieFed formats what it sends us:**
|
||||
- It **chooses the format by our NodeInfo software name**, so keep that accurate.
|
||||
@@ -659,7 +675,9 @@ What it showed:
|
||||
- **NodeBB:**
|
||||
- A topic's first post is an `Article` with `name`, **`summary` = an excerpt** and `preview`; replies are Notes.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -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
|
||||
(`/api/v2/search?resolve=true`, no `type=accounts`); reactions go through Pleroma's route
|
||||
(`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:
|
||||
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.
|
||||
@@ -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
|
||||
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.
|
||||
- **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
|
||||
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
|
||||
|
||||
+32
-10
@@ -82,7 +82,10 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
|
||||
it is kept, and a bare `Lock` from the post's server is taken (Mbin). A direct message goes out as a `ChatMessage`
|
||||
to someone who writes to us that way, and to Lemmy 0.19 and Mbin as NodeInfo names them (owner decision, G-0008
|
||||
closed).
|
||||
- [ ] P7 Threads, communities, moderation, the social graph
|
||||
- [ ] P7 Threads, communities, moderation, the social graph: in progress; v1.24.0 deployed 2026-10-08 (Lemmy's `Warn` and
|
||||
`Resolve`, flairs, FEP-268d `searchableBy`, thread backfill with `contextHistory` and ETags, feeds read and followed
|
||||
through `privapub_feeds`, PieFed's post `Move`, downvotes out). Left: featured and moderators listings, link posts out,
|
||||
community polls, the outbound shape Lemmy requires
|
||||
- [ ] P8 Signatures, discovery, the long tail
|
||||
- [x] P9 Reading at volume (owner decision 2026-10-04, back from "Cut"): lists, server-side filters, scheduled posts,
|
||||
followed hashtags, trends and a directory (the server side, 2026-10-05; decePub's screens follow)
|
||||
@@ -221,6 +224,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. |
|
||||
| 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}` |
|
||||
| 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)
|
||||
|
||||
@@ -300,7 +304,7 @@ restore system (JSON mostly, plus the files).
|
||||
| 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. |
|
||||
|
||||
**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,
|
||||
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
|
||||
@@ -310,9 +314,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
|
||||
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
|
||||
group, the nginx locations), the deploy, `PrivaPub admin media audit` and, once its report is read, `--fix`; and the
|
||||
owner's word on deleting the old pre-deploy dumps, which hold statistics salts.
|
||||
**Deployed 2026-10-07, in this order:** `setup.sh` (production's mongod is now the replica set `rs0`; the backups
|
||||
directory; the nginx locations), then v1.23.0, whose own pre-deploy backup was verified whole, then decePub v1.5.0.
|
||||
`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)
|
||||
|
||||
@@ -675,8 +681,11 @@ it, raw where it doesn't.
|
||||
|
||||
#### P7 Threads, communities and the social graph
|
||||
- **Thread backfill:**
|
||||
- read in order: `contextHistory`, then `context` (paged, with ETag), then `replies`. **Done (2026-10-05), but for
|
||||
`contextHistory` and ETags:** a persona opening a public remote thread queues `FetchReplies` for the post and its
|
||||
- read in order: `contextHistory`, then `context` (paged, with ETag), then `replies`. **Done (2026-10-05; `contextHistory`
|
||||
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,
|
||||
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
|
||||
@@ -686,8 +695,19 @@ it, raw where it doesn't.
|
||||
public and unlisted replies only; Mastodon finds a thread's replies through them, checked live).
|
||||
- **Lemmy, PieFed and Mbin:**
|
||||
- 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`;
|
||||
- votes in and out; link posts; flairs; `Feed` actors; community polls; post `Move`;
|
||||
ban of a persona refuses its posts there and shows as `blocked_by`), featured; `Warn` and `Resolve` (**done
|
||||
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;
|
||||
- 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);
|
||||
@@ -706,7 +726,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
|
||||
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);
|
||||
- `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
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user