Reports reach Lemmy's moderators, from an anonymous reporter

Lemmy takes a report only from a person or a service, about one post or comment, addressed to its community, and it
answered PrivaPub's Flag (the instance actor's, an Application, with no `to` and the account and posts as its object)
400. A report of a post or comment in a community on a server whose NodeInfo names Lemmy now leaves from
`privapub_reports`, a Service with its own key that names nobody: one Flag per post, `to` the community (its own
audience, else its thread's), with the persona's words, or the category, in `summary` and `content`, sent to the
community's inbox. This is the second exception to "a server's software is for display" (owner decision 2026-10-06,
`ReportService.ServiceReportTakers`). Every other server keeps the instance actor's report. An account alone is not
reported to Lemmy, which takes no such report, and `forwarded` now says whether anything left.

The reporter is read unsigned in SecureMode and answers WebFinger like the instance actor. Nobody follows or mentions
it, the Mastodon API has no account for it, and a migration reserves its name. Checked live: Lemmy 1.0 and 0.19 keep the
reports of a thread and of a comment, with alice's words, from "Reports from privapub.test", and none names her (69
checks). A sweep of every scenario with this and the next commit: 876 checks pass; Ghost's Network feed listed alice's
post too late once, and Ghost passes alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-06 19:23:17 +02:00
1 parent 10ff4b3d2a
commit f66c280b0b
33 files changed
+561 -46

No files matched your search

+11 -5
View File
@@ -21,6 +21,9 @@ Its defining idea: **one private login owns several public personas.**
becoming two kinds: a public **community** (FEP-1b12, Lemmy-compatible) and a private, invitation-only **circle**.
- **`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 names nobody. Both 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;
@@ -245,10 +248,12 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
16. **An `Accept` or `Reject` is routed by what it answers:** our Follow (`FollowService`, any `-again-N` resend), an
interaction request (`InteractionApprovals`, with the author's authorization read back from its origin) or a
persona's `Join` (`Participations`, by its `/grunts/join-<id>` id), each only from the origin of what it answers.
17. **A server's software is for display, with one exception** (owner decision 2026-10-05): a direct message to one
account on a server whose NodeInfo names Lemmy before 1.0 or Mbin goes as a `ChatMessage`
(`StatusService.TakesOnlyChatMessages`), as one to an account that writes to us that way does. Nothing else may
branch on `RemoteInstance.Software`.
17. **A server's software is for display, with two exceptions** (owner decisions 2026-10-05 and 2026-10-06): a direct
message to one account on a server whose NodeInfo names Lemmy before 1.0 or Mbin goes as a `ChatMessage`
(`StatusService.TakesOnlyChatMessages`), as one to an account that writes to us that way does; and a report of a
post in a community on a server whose NodeInfo is in `ReportService.ServiceReportTakers` leaves in Lemmy's shape,
from the reporter, one `Flag` per post. A server joins that set only once the pasture shows it keeps such a report
with its reason. Nothing else may branch on `RemoteInstance.Software`.
## Mastodon client API invariants
@@ -327,7 +332,8 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
see ids. Never emit `CreatedAt`.
- **Per-avatar state stays per avatar:** blocks, mutes, notifications, follows. Nothing may relate sibling avatars.
- **Blocks federate** (owner decision, 2026-10-01): a block is sent as `Block` from the blocking avatar, an unblock as
`Undo{Block}`. Reports still leave as `Flag` from the instance actor, never from the reporting avatar.
`Undo{Block}`. Reports still leave as `Flag` from the server's own actors (the instance actor, or the reporter for
Lemmy's communities), never from the reporting avatar.
- **What PrivaPub reveals is the owner's call.** Previews, blocks, website authorship, views, bridging and reactions were
decided in `docs/ROADMAP.md` ("Owner decisions on what PrivaPub reveals"). Anything new that tells another server
something about an avatar gets the same treatment: ask, then record it there.
+15 -3
View File
@@ -133,6 +133,9 @@ The names are the project's own and are stable; resolve actors through WebFinger
is used to read another server's content. It also answers at the server's root (`/`) for a request that asks for
ActivityPub, as Lemmy's site actor does: PieFed sends a community's announces to the inbox of the Application at a
peer's root, and to `/inbox` when there is none.
- 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 takes only from a person or a service (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.
## Groups
@@ -225,10 +228,18 @@ persona's posts passed on to its followers. A deleted post answers 410 with a `T
is told to the post's audience as `Add` or `Remove` on `featured`, as Mastodon tells it.
- **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it
followed); an unblock sends `Undo{Block}`.
- **Reports** are sent as `Flag` by the instance actor, never by the reporting account.
- **Reports** are sent as `Flag`, never by the reporting account and never naming it.
- To the reported account's server: from the instance actor, with the account and the posts as `object` and the
persona's words in `content`.
- To a community on a server whose NodeInfo names Lemmy (owner decision 2026-10-06, the second place PrivaPub decides
by a server's software): one `Flag` per reported post or comment that was made in that community (its own
`audience`, else its thread's), from the reporter, `to` the community, the post alone as `object`, the words (or,
with none, the category) in `summary` and `content`, sent to the community's inbox. Lemmy refuses a Flag from an
`Application`, with no `to` or with no reason. Such a server gets no instance actor's Flag: an account alone, or a
post outside its communities, is not reported there, since Lemmy takes neither.
- **Direct messages** go out as a `Note` addressed to their recipients, except a message to one account elsewhere that
writes to us as `ChatMessage`s (Pleroma's type), or whose server takes nothing else: Lemmy before 1.0 and Mbin, as
their NodeInfo names them (owner decision 2026-10-05, the one place PrivaPub decides by a server's software). That
their NodeInfo names them (owner decision 2026-10-05, the first place PrivaPub decides by a server's software). That
message goes out as a `ChatMessage`, to the account alone, without a mention in its text.
- **Replies are passed on** (inbox forwarding; owner decision 2026-10-05). A public or unlisted reply from another server
to a persona's public, unlisted or followers-only post goes on to the persona's followers as its author's server sent
@@ -382,7 +393,8 @@ Posts with a location (shown to nearby users of this server) never leave the ser
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.
- **Reading our documents (SecureMode).** privapub.thepra.dev answers ActivityPub GETs only when they are signed, like
Mastodon's authorized fetch; the instance actor `/peasants/privapub` is the exception, since its key is needed first.
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.
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
@@ -70,6 +70,7 @@ namespace PrivaPub.Tests.Domain
public Task<LocalActor> FindByUri(string actorUri, CancellationToken token) => throw new NotSupportedException();
public Task<LocalActor> FindByAddress(string address, CancellationToken token) => throw new NotSupportedException();
public Task<LocalActor> GetInstanceActor(CancellationToken token) => throw new NotSupportedException();
public Task<LocalActor> GetReporterActor(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,245 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Signing;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
// Lemmy takes a report only from a person or a service, about one post or comment, addressed to its community (owner
// decision 2026-10-06): a report about a post in a community on a Lemmy leaves from the server's reporter, one per post.
// Alone in its collection: the server rows it writes for the peer's hosts decide for whoever reports there.
[Trait("Category", "Integration")]
[Xunit.Collection(nameof(Exclusive))]
public sealed class ServiceReportTests : IAsyncLifetime
{
static readonly string[] PeerHosts = { "localhost", "127.0.0.1" };
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
await DB.Default.DeleteAsync<RemoteInstance>(i => PeerHosts.Contains(i.Host));
}
public async ValueTask DisposeAsync()
{
if (_harness == default)
return;
await DB.Default.DeleteAsync<RemoteInstance>(i => PeerHosts.Contains(i.Host));
await _harness.DisposeAsync();
}
static CancellationToken Token => TestContext.Current.CancellationToken;
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
// what NodeInfo said the server at that host runs
static async Task Runs(string host, string software)
{
await DB.Default.DeleteAsync<RemoteInstance>(i => i.Host == host);
await DB.Default.SaveAsync(new RemoteInstance { Host = host, Software = software, SoftwareVersion = "1.0.0" }, Token);
}
async Task<ForeignAvatar> Known(RemoteActor actor) => await _harness.Remote.GetActor(actor.Id, refresh: false, Token);
static async Task<Post> Held(RemoteActor author, string community = default, Post parent = default)
{
var post = new Post
{
ObjectURI = $"{Origin(author)}/post/{Guid.NewGuid():N}",
ActorURI = author.Id,
AudienceURI = community,
AnsweringToPostId = parent?.ID,
ContentHtml = "<p>reported</p>"
};
await DB.Default.SaveAsync(post, Token);
return post;
}
async Task<List<(DeliveryPayload Payload, JsonObject Body)>> Queued() =>
(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver && j.CreatedAt >= DateTime.UtcNow.AddMinutes(-5)).ExecuteAsync(Token))
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
.Select(p => (p, JsonNode.Parse(p.Body)!.AsObject()))
.ToList();
// a community on a Lemmy (the peer as localhost), and one of its posters there
async Task<(RemoteActor Community, RemoteActor Poster)> OnLemmy()
{
var community = new RemoteActor(_harness.Peer, "cats", _harness.Peer.B, type: "Group", sharedInbox: true);
var poster = new RemoteActor(_harness.Peer, "poster", _harness.Peer.B);
await Known(community);
await Known(poster);
await Runs("localhost", "lemmy");
return (community, poster);
}
[Fact]
public async Task A_post_in_a_lemmy_community_is_reported_to_its_community_by_the_reporter_and_nowhere_else()
{
var (_, alice) = await _harness.Persona("alice");
var (community, poster) = await OnLemmy();
var post = await Held(poster, community.Id);
var reporter = await _harness.Local.GetReporterActor(Token);
var report = await _harness.Reports.File(alice, (await Known(poster)).ID, new[] { post.ID }, " a slur in the title ", "violation", forward: true, Token);
Assert.True(report.Forwarded);
var flag = Assert.Single(await _harness.Outgoing(community.SharedInbox));
Assert.Equal("Flag", flag["type"]!.GetValue<string>());
Assert.Equal(reporter.Uri, flag["actor"]!.GetValue<string>());
Assert.EndsWith("/peasants/privapub_reports", reporter.Uri);
Assert.StartsWith(reporter.Uri + "/", flag["id"]!.GetValue<string>());
Assert.Equal(new[] { community.Id }, flag["to"]!.AsArray().Select(t => t!.GetValue<string>()));
Assert.Equal(community.Id, flag["audience"]!.GetValue<string>());
Assert.Equal(post.ObjectURI, flag["object"]!.GetValue<string>());
Assert.Equal("a slur in the title", flag["summary"]!.GetValue<string>());
Assert.Equal("a slur in the title", flag["content"]!.GetValue<string>());
Assert.Empty(await _harness.Outgoing(poster.Id + "/inbox"));
Assert.DoesNotContain(await Queued(), q => q.Payload.Inbox != community.SharedInbox && q.Body.ToJsonString().Contains(post.ObjectURI));
Assert.DoesNotContain(alice.UserName, flag.ToJsonString());
Assert.DoesNotContain(alice.Id, flag.ToJsonString());
var queued = Assert.Single(await Queued(), q => q.Body["actor"]!.GetValue<string>() == reporter.Uri && q.Body["object"]!.GetValue<string>() == post.ObjectURI);
Assert.Equal(LocalActorKind.Reporter, queued.Payload.SignerKind);
}
[Fact]
public async Task The_reporter_signs_its_flag_with_its_own_key()
{
var (_, alice) = await _harness.Persona("alice");
var (community, poster) = await OnLemmy();
var post = await Held(poster, community.Id);
var reporter = await _harness.Local.GetReporterActor(Token);
var instance = await _harness.Local.GetInstanceActor(Token);
_harness.Peer.Answer("/inbox", 202);
await _harness.Reports.File(alice, (await Known(poster)).ID, new[] { post.ID }, default, "spam", forward: true, Token);
var job = await DB.Default.Find<Job>()
.Match(j => j.Kind == JobKind.Deliver && j.DedupeKey.StartsWith(reporter.ActivityUri($"flag-")))
.Sort(j => j.CreatedAt, Order.Descending).ExecuteFirstAsync(Token);
var handler = new DeliveryJobHandler(_harness.Local, Peer.Http(), new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions())),
NullLogger<DeliveryJobHandler>.Instance);
var outcome = await handler.Handle(job, Token);
Assert.Equal(JobResult.Done, outcome.Result);
var received = Assert.Single(_harness.Peer.Requests, r => r.Path == "/inbox" && r.Method == "POST");
// no words: Lemmy takes no report without a reason, so the category is it
Assert.Equal("spam", JsonNode.Parse(received.Body)!["summary"]!.GetValue<string>());
var signature = HttpSignatures.Parse(received.Signature);
Assert.Equal(reporter.KeyId, signature.KeyId);
Assert.NotEqual(instance.PublicKeyPem, reporter.PublicKeyPem);
var signingString = $"(request-target): post /inbox\nhost: {received.Headers["Host"]}\ndate: {received.Headers["Date"]}\ndigest: {received.Headers["Digest"]}";
using var key = RSA.Create();
key.ImportFromPem(reporter.PublicKeyPem);
Assert.True(key.VerifyData(Encoding.UTF8.GetBytes(signingString), signature.Signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
}
[Fact]
public async Task A_comment_finds_its_community_through_its_thread_and_two_posts_are_two_flags()
{
var (_, alice) = await _harness.Persona("alice");
var (community, poster) = await OnLemmy();
var thread = await Held(poster, community.Id);
var comment = await Held(poster, parent: thread);
var answer = await Held(poster, parent: comment);
var report = await _harness.Reports.File(alice, (await Known(poster)).ID, new[] { comment.ID, answer.ID }, "harassment", "other", forward: true, Token);
Assert.True(report.Forwarded);
var flags = await _harness.Outgoing(community.SharedInbox);
Assert.Equal(2, flags.Count);
Assert.Equal(new[] { comment.ObjectURI, answer.ObjectURI }.Order(), flags.Select(f => f["object"]!.GetValue<string>()).Order());
Assert.All(flags, f => Assert.Equal(community.Id, f["to"]![0]!.GetValue<string>()));
Assert.Equal(2, flags.Select(f => f["id"]!.GetValue<string>()).Distinct().Count());
}
[Fact]
public async Task An_account_alone_on_a_lemmy_is_reported_to_nobody()
{
var (_, alice) = await _harness.Persona("alice");
var (community, poster) = await OnLemmy();
var report = await _harness.Reports.File(alice, (await Known(poster)).ID, Array.Empty<string>(), "a spammer", "spam", forward: true, Token);
Assert.False(report.Forwarded);
Assert.False((await DB.Default.Find<PrivaPub.Models.Social.Report>().OneAsync(report.ID, Token)).Forwarded);
Assert.Empty(await _harness.Outgoing(community.SharedInbox));
Assert.Empty(await _harness.Outgoing(poster.Id + "/inbox"));
}
[Fact]
public async Task Any_other_server_still_gets_the_instance_flag_and_a_lemmy_community_its_own()
{
var (_, alice) = await _harness.Persona("alice");
var (community, _) = await OnLemmy();
// an account on a Mastodon (the peer as 127.0.0.1) that wrote in the community on the Lemmy, and elsewhere
var mastodonian = new RemoteActor(_harness.Peer, "masto");
await Runs("127.0.0.1", "mastodon");
var inCommunity = await Held(mastodonian, community.Id);
var elsewhere = await Held(mastodonian);
var instance = await _harness.Local.GetInstanceActor(Token);
var report = await _harness.Reports.File(alice, (await Known(mastodonian)).ID, new[] { inCommunity.ID, elsewhere.ID }, "spam", "spam", forward: true, Token);
Assert.True(report.Forwarded);
var toCommunity = Assert.Single(await _harness.Outgoing(community.SharedInbox));
Assert.Equal(inCommunity.ObjectURI, toCommunity["object"]!.GetValue<string>());
var toAuthor = Assert.Single(await _harness.Outgoing(mastodonian.Id + "/inbox"));
Assert.Equal(instance.Uri, toAuthor["actor"]!.GetValue<string>());
Assert.Equal(new[] { mastodonian.Id, inCommunity.ObjectURI, elsewhere.ObjectURI }.Order(),
toAuthor["object"]!.AsArray().Select(o => o!.GetValue<string>()).Order());
Assert.Null(toAuthor["to"]);
}
[Fact]
public async Task A_community_on_a_server_that_takes_no_service_reports_gets_nothing_new()
{
var (_, alice) = await _harness.Persona("alice");
var community = new RemoteActor(_harness.Peer, "forum", _harness.Peer.B, type: "Group", sharedInbox: true);
var poster = new RemoteActor(_harness.Peer, "poster", _harness.Peer.B);
await Known(community);
var foreign = await Known(poster);
await Runs("localhost", "friendica");
var post = await Held(poster, community.Id);
var report = await _harness.Reports.File(alice, foreign.ID, new[] { post.ID }, "spam", "spam", forward: true, Token);
Assert.True(report.Forwarded);
Assert.Empty(await _harness.Outgoing(community.SharedInbox));
var flag = Assert.Single(await _harness.Outgoing(poster.Id + "/inbox"));
Assert.EndsWith("/peasants/privapub", flag["actor"]!.GetValue<string>());
}
[Fact]
public async Task Nobody_follows_the_reporter()
{
var follower = new RemoteActor(_harness.Peer, "follower");
var reporter = await _harness.Local.GetReporterActor(Token);
var result = await _harness.Deliver(follower, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(follower)}/follow/{Guid.NewGuid():N}", ["type"] = "Follow", ["actor"] = follower.Id, ["object"] = reporter.Uri
});
Assert.Equal(404, result.StatusCode);
Assert.False(await DB.Default.Find<Follower>().Match(f => f.ActorURI == follower.Id).ExecuteAnyAsync(Token));
}
}
}
+35 -1
View File
@@ -115,6 +115,37 @@ namespace PrivaPub.Tests.Http
Assert.Equal(HttpStatusCode.NotFound, browser.Status);
}
// Lemmy takes a report only from a Person, a Service or an Organization, whose document names its preferredUsername,
// inbox, outbox and key; a browser gets the document too, as from the instance actor: the reporter has no page
[Fact]
public async Task The_reporter_is_a_service_with_an_inbox_an_outbox_and_its_own_key()
{
var fetched = await _client.Fetch("/peasants/privapub_reports");
var browser = await _client.Fetch("/peasants/privapub_reports", Browser);
var outbox = await _client.Fetch("/peasants/privapub_reports/anus");
var instance = await _client.Fetch("/peasants/privapub");
Assert.Equal(HttpStatusCode.OK, fetched.Status);
var actor = fetched.Json;
var id = $"{Base}/peasants/privapub_reports";
Assert.Equal(id, actor["id"]!.GetValue<string>());
Assert.Equal("Service", actor["type"]!.GetValue<string>());
Assert.Equal("privapub_reports", actor["preferredUsername"]!.GetValue<string>());
Assert.Equal(id + "/mouth", actor["inbox"]!.GetValue<string>());
Assert.Equal(id + "/anus", actor["outbox"]!.GetValue<string>());
Assert.Equal(id, actor["url"]!.GetValue<string>());
Assert.Equal(id + "#main-key", actor["publicKey"]!["id"]!.GetValue<string>());
Assert.Equal(id, actor["publicKey"]!["owner"]!.GetValue<string>());
Assert.NotEqual(instance.Json["publicKey"]!["publicKeyPem"]!.GetValue<string>(), actor["publicKey"]!["publicKeyPem"]!.GetValue<string>());
Assert.False(actor["discoverable"]!.GetValue<bool>());
Assert.Null(actor["wall"]);
Assert.Null(actor["implements"]);
Assert.Equal(HttpStatusCode.OK, browser.Status);
Assert.Equal("Service", browser.Json["type"]!.GetValue<string>());
Assert.Equal(HttpStatusCode.OK, outbox.Status);
Assert.Equal(0, outbox.Json["totalItems"]!.GetValue<int>());
}
[Fact]
public async Task Users_moves_permanently_to_peasants_and_unknown_actors_are_404()
{
@@ -507,7 +538,7 @@ namespace PrivaPub.Tests.Http
}
[Fact]
public async Task Secure_mode_refuses_unsigned_gets_except_for_the_instance_actor()
public async Task Secure_mode_refuses_unsigned_gets_except_for_the_servers_own_actors()
{
var secure = await SecureModeHost.Shared();
using var client = secure.Client();
@@ -533,6 +564,9 @@ namespace PrivaPub.Tests.Http
var instance = await client.Fetch("/peasants/privapub");
Assert.Equal(HttpStatusCode.OK, instance.Status);
Assert.Equal("Application", instance.Json["type"]!.GetValue<string>());
var reporter = await client.Fetch("/peasants/privapub_reports");
Assert.Equal(HttpStatusCode.OK, reporter.Status);
Assert.Equal("Service", reporter.Json["type"]!.GetValue<string>());
Assert.Equal(HttpStatusCode.OK, (await client.Fetch("/")).Status);
foreach (var path in paths[..^1])
Assert.True((await client.Fetch(reader.SignedGet(path))).Status == HttpStatusCode.OK, $"{path} refused a signed GET");
+2 -1
View File
@@ -127,7 +127,7 @@ namespace PrivaPub.Tests.Http
}
[Fact]
public async Task Lookup_finds_local_and_remote_accounts_but_never_a_circle_or_the_instance_actor()
public async Task Lookup_finds_local_and_remote_accounts_but_never_a_circle_or_the_servers_own_actors()
{
var alice = await _host.Mastodon("alice");
var (bob, bobId) = await Remote();
@@ -149,6 +149,7 @@ namespace PrivaPub.Tests.Http
Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get($"/api/v1/accounts/lookup?acct={circleName}")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/accounts/lookup?acct={circleName}")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get("/api/v1/accounts/lookup?acct=privapub")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get("/api/v1/accounts/lookup?acct=privapub_reports")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get($"/api/v1/accounts/lookup?acct=nobody{Guid.NewGuid():N}")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get("/api/v1/accounts/lookup?acct=a@b@c")).Status);
+5 -1
View File
@@ -108,7 +108,7 @@ namespace PrivaPub.Tests.Http
}
[Fact]
public async Task WebFinger_finds_the_instance_actor_and_a_community()
public async Task WebFinger_finds_the_servers_own_actors_and_a_community()
{
var owner = await _host.Persona(await _host.SignUp(), "fingerowner");
var community = await _host.FederatedGroup(owner, community: true);
@@ -120,6 +120,10 @@ namespace PrivaPub.Tests.Http
Assert.Equal($"acct:privapub@{Domain}", instance.Json["subject"]!.GetValue<string>());
Assert.Equal($"{Base}/peasants/privapub", Link(instance.Json, "self"));
Assert.Single(instance.Json["links"]!.AsArray());
var reporter = await WebFinger($"acct:privapub_reports@{Domain}");
Assert.Equal(HttpStatusCode.OK, reporter.Status);
Assert.Equal($"{Base}/peasants/privapub_reports", Link(reporter.Json, "self"));
Assert.Single(reporter.Json["links"]!.AsArray());
Assert.Equal(HttpStatusCode.OK, group.Status);
Assert.Equal($"acct:{community.UserName}@{Domain}", group.Json["subject"]!.GetValue<string>());
Assert.Equal(community.Uri, Link(group.Json, "self"));
@@ -59,6 +59,8 @@ namespace PrivaPub.Tests.Infrastructure
Assert.True(await local.IsUserNameTaken(name, token));
Assert.False(await local.TryReserveUserName("admin", LocalActorKind.Person, "c", token));
Assert.False(await local.TryReserveUserName(LocalActorService.InstanceUserName, LocalActorKind.Person, "c", token));
Assert.False(await local.TryReserveUserName(LocalActorService.ReporterUserName, LocalActorKind.Group, "c", token));
Assert.True(await local.IsUserNameTaken("PrivaPub_Reports", token));
}
}
}
@@ -115,7 +115,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
{
var local = await _localActors.FindByUserName(parts[0], token);
return local is { IsFederated: true, IsCircle: false, Kind: not LocalActorKind.Application } ? Json(await _mapper.Local(local, false, token)) : NotFoundError();
return local is { IsFederated: true, IsCircle: false, IsServerActor: false } ? Json(await _mapper.Local(local, false, token)) : NotFoundError();
}
var userName = parts[0];
var domain = parts[1].ToLowerInvariant();
+1 -1
View File
@@ -122,7 +122,7 @@ namespace PrivaPub.Domain.Content
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
{
var local = await _localActors.FindByUserName(parts[0], token);
return local is { IsFederated: true, IsCircle: false, Kind: not LocalActorKind.Application }
return local is { IsFederated: true, IsCircle: false, IsServerActor: false }
? new ResolvedMention(local.Uri, local.UserName, local.Domain, local.Uri, true, local.Id, local.Inbox)
: default;
}
+54 -3
View File
@@ -1,6 +1,7 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Social;
@@ -50,12 +51,41 @@ namespace PrivaPub.Domain.Relationships
PostIds = posts.Select(p => p.ID).ToList(),
ObjectURIs = posts.Select(p => p.ObjectURI).ToList(),
Comment = comment?.Trim(),
Category = category is "spam" or "legal" or "violation" ? category : "other",
Forwarded = forward && remoteTarget != default
Category = category is "spam" or "legal" or "violation" ? category : "other"
};
await DB.Default.SaveAsync(report, token);
if (!forward || remoteTarget == default)
return report;
if (report.Forwarded)
// to the community of each post made in one on a server that takes reports only in Lemmy's shape
var flagged = 0;
var reason = string.IsNullOrEmpty(report.Comment) ? report.Category : report.Comment;
foreach (var post in posts)
{
var community = await Communities.Of(post, _dbEntities, token);
if (community == default || !await TakesServiceReports(community, token))
continue;
var group = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == community).ExecuteFirstAsync(token);
var inbox = string.IsNullOrEmpty(group?.SharedInboxURL) ? group?.InboxURL : group.SharedInboxURL;
if (string.IsNullOrEmpty(inbox))
continue;
var serviceReporter = await _localActors.GetReporterActor(token);
await _delivery.Enqueue(serviceReporter, new[] { inbox }, new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = serviceReporter.ActivityUri($"flag-{report.ID}-{++flagged}"),
["type"] = "Flag",
["actor"] = serviceReporter.Uri,
["to"] = new JsonArray(community),
["audience"] = community,
["object"] = post.ObjectURI,
["summary"] = reason,
["content"] = reason
}, token);
}
// every other server, the author's, as before: an account alone, or posts outside such communities, go nowhere when
// the author's server takes reports only in Lemmy's shape
if (!await TakesServiceReports(targetUri, token))
{
var instance = await _localActors.GetInstanceActor(token);
var flag = new JsonObject
@@ -68,8 +98,29 @@ namespace PrivaPub.Domain.Relationships
["object"] = new JsonArray(report.ObjectURIs.Prepend(targetUri).Select(u => (JsonNode)u).ToArray())
};
await _delivery.Enqueue(instance, new[] { remoteTarget.SharedInboxURL ?? remoteTarget.InboxURL }, flag, token);
flagged++;
}
if (flagged > 0)
{
report.Forwarded = true;
await DB.Default.Update<Report>().MatchID(report.ID).Modify(r => r.Forwarded, true).ExecuteAsync(token);
}
return report;
}
// The second place PrivaPub decides by a server's software (owner decision 2026-10-06): Lemmy takes a report only from a
// person or a service, about one post or comment, addressed to its community, so a report for it leaves in that shape
// from the server's anonymous reporter; a server joins only once the pasture shows it stores such a report with its
// reason. NodeInfo names it: PieFed and Mbin speak Lemmy's shapes, and nothing else tells them apart.
static readonly HashSet<string> ServiceReportTakers = new(StringComparer.Ordinal) { "lemmy" };
static async Task<bool> TakesServiceReports(string actorUri, CancellationToken token)
{
if (!Uri.TryCreate(actorUri, UriKind.Absolute, out var uri))
return false;
var host = uri.Host;
var instance = await DB.Default.Find<Models.Jobs.RemoteInstance>().Match(i => i.Host == host).ExecuteFirstAsync(token);
return instance?.Software is { } software && ServiceReportTakers.Contains(software.ToLowerInvariant());
}
}
}
+1 -1
View File
@@ -96,7 +96,7 @@ namespace PrivaPub.Domain.Social
var (local, remote) = await ResolveTarget(target, token);
if (local == default && remote == default)
return default;
if (local != default && (local.Id == follower.Id || !local.IsFederated || local.IsCircle || local.Kind == LocalActorKind.Application))
if (local != default && (local.Id == follower.Id || !local.IsFederated || local.IsCircle || local.IsServerActor))
return default;
var targetUri = local?.Uri ?? remote.ActorURI;
@@ -50,6 +50,8 @@ namespace PrivaPub.Federation.Actors
public string FeaturedTags => $"{Uri}/tattoos";
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 string Flock => $"{Uri}/flock";
public string Wardens => $"{Uri}/wardens";
public string SharedInbox => $"{BaseAddress}/human-centipede";
@@ -71,6 +73,7 @@ namespace PrivaPub.Federation.Actors
Task<LocalActor> FindByUri(string actorUri, CancellationToken token);
Task<LocalActor> FindByAddress(string address, CancellationToken token);
Task<LocalActor> GetInstanceActor(CancellationToken token);
Task<LocalActor> GetReporterActor(CancellationToken token);
Task<bool> IsUserNameTaken(string userName, CancellationToken token);
Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token);
LocalActor FromAvatar(Avatar avatar);
@@ -83,16 +86,23 @@ namespace PrivaPub.Federation.Actors
public class LocalActorService : ILocalActorService
{
public const string InstanceUserName = "privapub";
// 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";
public static bool IsServerActorName(string userName) =>
string.Equals(userName, InstanceUserName, StringComparison.OrdinalIgnoreCase) || string.Equals(userName, ReporterUserName, StringComparison.OrdinalIgnoreCase);
static readonly HashSet<string> ReservedByInstance = new(StringComparer.Ordinal)
{
InstanceUserName, "admin", "administrator", "root", "system", "support", "help", "moderator", "mod",
InstanceUserName, ReporterUserName, "admin", "administrator", "root", "system", "support", "help", "moderator", "mod",
"abuse", "postmaster", "webmaster", "hostmaster", "security", "noreply", "no_reply", "null", "undefined"
};
readonly DbEntities _dbEntities;
readonly IOptionsMonitor<AppConfiguration> _appConfiguration;
InstanceActor _instanceActor;
ReporterActor _reporterActor;
public LocalActorService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration)
{
@@ -109,6 +119,8 @@ namespace PrivaPub.Federation.Actors
userName = userName.ToLowerInvariant();
if (userName == InstanceUserName)
return await GetInstanceActor(token);
if (userName == ReporterUserName)
return await GetReporterActor(token);
var avatar = await _dbEntities.Avatars
.Match(a => a.UserName == userName && !a.DeletionAt.HasValue)
@@ -144,6 +156,8 @@ namespace PrivaPub.Federation.Actors
case LocalActorKind.Group:
var group = await _dbEntities.Groups.MatchID(id).ExecuteFirstAsync(token);
return group == default ? default : FromGroup(group);
case LocalActorKind.Reporter:
return await GetReporterActor(token);
default:
return await GetInstanceActor(token);
}
@@ -187,6 +201,37 @@ namespace PrivaPub.Federation.Actors
};
}
public async Task<LocalActor> GetReporterActor(CancellationToken token)
{
var reporter = _reporterActor ??= await LoadReporterActor(token);
return new LocalActor
{
Id = reporter.ID,
Kind = LocalActorKind.Reporter,
UserName = ReporterUserName,
Name = $"Reports from {new Uri(BaseAddress).Host}",
Summary = "It carries this PrivaPub server's reports to the servers that take them only from a person or a service; "
+ "it never names who made them.",
PrivateKeyPem = reporter.PrivateKey,
PublicKeyPem = reporter.PublicKey,
Discoverable = false,
Published = reporter.CreationDate,
BaseAddress = BaseAddress
};
}
async Task<ReporterActor> LoadReporterActor(CancellationToken token)
{
var reporter = await _dbEntities.ReporterActors.Sort(r => r.CreationDate, Order.Ascending).ExecuteFirstAsync(token);
if (reporter != default)
return reporter;
var (privateKey, publicKey) = Keys.NewKeyPair();
reporter = new ReporterActor { PrivateKey = privateKey, PublicKey = publicKey };
await DB.Default.SaveAsync(reporter, token);
return reporter;
}
async Task<InstanceActor> LoadInstanceActor(CancellationToken token)
{
var instance = await _dbEntities.InstanceActors.Sort(i => i.CreationDate, Order.Ascending).ExecuteFirstAsync(token);
@@ -69,7 +69,7 @@ namespace PrivaPub.Federation.Controllers
};
if (local is not { IsFederated: true })
return NotFound();
if (WantsHtml() && local.Kind != LocalActorKind.Application)
if (WantsHtml() && !local.IsServerActor)
return Redirect(local.HtmlUrl);
return Activity(ActivityPubRenderer.Actor(local));
}
@@ -531,10 +531,11 @@ 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 instance actor, whose key
// peers need first, and except for browsers, which only get redirected to the public pages
// 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
if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method) && !WantsHtml() && Request.Path.Value != "/"
&& !string.Equals(context.RouteData.Values["actor"] as string, LocalActorService.InstanceUserName, StringComparison.OrdinalIgnoreCase)
&& !LocalActorService.IsServerActorName(context.RouteData.Values["actor"] as string)
&& await _fetches.Requester(Request, HttpContext.RequestAborted) == default)
{
context.Result = StatusCode(StatusCodes.Status401Unauthorized);
@@ -74,8 +74,8 @@ namespace PrivaPub.Federation.Controllers
var document = new JsonObject
{
["subject"] = $"acct:{actor.Handle}",
["aliases"] = actor.Kind == LocalActorKind.Application ? new JsonArray(actor.Uri) : new JsonArray(actor.HtmlUrl, actor.Uri),
["links"] = actor.Kind == LocalActorKind.Application
["aliases"] = actor.IsServerActor ? new JsonArray(actor.Uri) : new JsonArray(actor.HtmlUrl, actor.Uri),
["links"] = actor.IsServerActor
? new JsonArray(new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = actor.Uri })
: new JsonArray(
new JsonObject { ["rel"] = "http://webfinger.net/rel/profile-page", ["type"] = "text/html", ["href"] = actor.HtmlUrl },
+1
View File
@@ -66,6 +66,7 @@ namespace PrivaPub.Federation.Inbox
LocalActorKind.Person => "person",
LocalActorKind.Group when !local.IsCircle => "group",
LocalActorKind.Application => "application",
LocalActorKind.Reporter => "reporter",
_ => default
};
}
+24
View File
@@ -0,0 +1,24 @@
using MongoDB.Entities;
using PrivaPub.StaticServices;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Inbox
{
public static class Communities
{
// the remote community a post was made in: its own audience, or the first one its thread names on the way up (a
// comment fetched for its thread, or delivered to a persona, may name none)
public static async Task<string> Of(PostEntity post, DbEntities dbEntities, CancellationToken token)
{
for (var depth = 0; post != default && depth < RemotePosts.MaxDepth; depth++)
{
if (!string.IsNullOrEmpty(post.AudienceURI))
return post.AudienceURI;
post = string.IsNullOrEmpty(post.AnsweringToPostId) ? default : await dbEntities.Posts.MatchID(post.AnsweringToPostId).ExecuteFirstAsync(token);
}
return default;
}
}
}
@@ -285,13 +285,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
var post = target == default
? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == target && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
for (var depth = 0; post != default && depth < RemotePosts.MaxDepth; depth++)
{
if (post.AudienceURI == group.ActorURI)
return true;
post = string.IsNullOrEmpty(post.AnsweringToPostId) ? default : await _dbEntities.Posts.MatchID(post.AnsweringToPostId).ExecuteFirstAsync(token);
}
return false;
return post != default && await Communities.Of(post, _dbEntities, token) == group.ActorURI;
}
// A community's moderators lock one of its posts, or unlock it: no more replies, ours included. The community vouches
@@ -44,7 +44,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
var follower = actor;
var target = await _localActors.FindByAddress(Id(follow["object"]), token);
if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application)
if (target is not { IsFederated: true } || target.IsServerActor)
{
Arrival.Drop("unknown-recipient");
return;
+1 -1
View File
@@ -254,7 +254,7 @@ namespace PrivaPub.Federation.Inbox
case "Follow":
// (by its actor's id, or the profile page Forte names instead)
var target = await _localActors.FindByAddress(Id(inner), token);
if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application)
if (target is not { IsFederated: true } || target.IsServerActor)
return new(StatusCodes.Status404NotFound, "no such local actor", Reason: "unknown-recipient");
break;
case "Undo" when inner is JsonObject && Id(inner["actor"]) != actorUri:
@@ -181,6 +181,7 @@ namespace PrivaPub.Federation.Outbox
{ IsCircle: true } => default,
{ Kind: LocalActorKind.Person } => "person",
{ Kind: LocalActorKind.Group } => "group",
{ Kind: LocalActorKind.Reporter } => "reporter",
_ => "application"
},
Signature = "cavage:rsa-sha256"
@@ -125,13 +125,14 @@ namespace PrivaPub.Federation.Rendering
{
LocalActorKind.Group => "Group",
LocalActorKind.Application => "Application",
LocalActorKind.Reporter => "Service",
_ when actor.IsBot => "Service",
_ => "Person"
},
["preferredUsername"] = actor.UserName,
["name"] = actor.Name,
["summary"] = Html(actor.Summary),
["url"] = actor.Kind == LocalActorKind.Application ? actor.Uri : actor.HtmlUrl,
["url"] = actor.IsServerActor ? actor.Uri : actor.HtmlUrl,
["inbox"] = actor.Inbox,
["outbox"] = actor.Outbox,
["followers"] = actor.Followers,
@@ -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 anonymous reporter's name (LocalActorService.ReporterUserName) is the server's, as the instance actor's is: a
// persona or group already holding it would be hidden behind the reporter, so the upgrade stops and says so
public class _015_the_reporter_has_its_name : IMigration
{
public async Task UpgradeAsync()
{
var held = await DB.Default.Find<ReservedName>().Match(r => r.Name == LocalActorService.ReporterUserName).ExecuteFirstAsync();
if (held is { OwnerKind: LocalActorKind.Reporter })
return;
if (held != default)
throw new InvalidOperationException(
$"The name {LocalActorService.ReporterUserName} belongs to a {held.OwnerKind} ({held.OwnerId}); rename it before upgrading");
await DB.Default.SaveAsync(new ReservedName { Name = LocalActorService.ReporterUserName, OwnerKind = LocalActorKind.Reporter });
}
}
}
+2 -1
View File
@@ -18,6 +18,7 @@ namespace PrivaPub.Models.Federation
{
Person,
Group,
Application
Application,
Reporter//the anonymous Service that carries reports to Lemmy (LocalActorService.ReporterUserName)
}
}
@@ -0,0 +1,12 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Federation
{
// the server's anonymous reporter (LocalActorService.ReporterUserName), its keys
public class ReporterActor : Entity
{
public string PrivateKey { get; set; }
public string PublicKey { get; set; }
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
}
}
+3 -2
View File
@@ -11,8 +11,9 @@ namespace PrivaPub.Models.Jobs
public DateTime? LastFailureAt { get; set; }
public string LastError { get; set; }
public string Software { get; set; }//NodeInfo software.name: for display, never for deciding behaviour, but for the one
//owner decision (2026-10-05) that a first private message to Lemmy before 1.0 or Mbin is a ChatMessage
public string Software { get; set; }//NodeInfo software.name: for display, never for deciding behaviour, but for two
//owner decisions: a first private message to Lemmy before 1.0 or Mbin is a ChatMessage (2026-10-05), and a report
//to a Lemmy community leaves from the reporter in Lemmy's shape (2026-10-06, ReportService.ServiceReportTakers)
public string SoftwareVersion { get; set; }
public string NodeName { get; set; }
public List<string> Protocols { get; set; } = new();
+1
View File
@@ -33,6 +33,7 @@ namespace PrivaPub.StaticServices
public Find<Follower> Followers { get { return DB.Default.Find<Follower>(); } }
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<Following> Followings { get { return DB.Default.Find<Following>(); } }
public Find<TimelineEntry> TimelineEntries { get { return DB.Default.Find<TimelineEntry>(); } }
+1 -1
View File
@@ -61,7 +61,7 @@ namespace PrivaPub.Web.Pages
public async Task<IActionResult> OnGetAsync(string user, CancellationToken token)
{
Actor = await _localActors.FindByUserName(user, token);
if (Actor is not { IsFederated: true, IsCircle: false } || Actor.Kind == LocalActorKind.Application)
if (Actor is not { IsFederated: true, IsCircle: false } || Actor.IsServerActor)
return NotFound();
if (WantsActivityJson())
return Redirect(Actor.Uri);
+13 -5
View File
@@ -502,10 +502,18 @@ on a Page: pins live in `featured`, locks in `Lock`.
- **Accepted types:** Page, Article, Note, Video and Event become posts. **`Question` is dropped.**
- **Anti-spam:** activities for a community are accepted only if a local user follows it.
- **Actors:** a Create, vote, moderation action or Flag must come from a Person, Service or Organization. **A Group or
Application actor fails to parse**, so moderation comes from the moderator Person. Our Flags, sent by an
Application instance actor with no `to` and an array `object` (the account and the post), are refused: confirmed live
2026-10-06, Lemmy 1.0.0-beta.2 answers 400 and keeps no report. Reaching its moderators needs an anonymous
`Service`-typed reporter, `to: [the post's community]` and the post alone as `object`.
Application actor fails to parse**, so moderation comes from the moderator Person. Our Flags, sent by the
Application instance actor with no `to` and an array `object` (the account and the post), were refused: confirmed
live 2026-10-06, Lemmy 1.0.0-beta.2 answered 400 and kept no report.
- **Reports reach its moderators since 2026-10-06** (owner decision, `ReportService.ServiceReportTakers`): a report of a
post or comment in a community on a server whose NodeInfo names Lemmy leaves from the anonymous `Service`
`privapub_reports`, one `Flag` per post, `to: [the community]`, the post alone as `object`, the persona's words in
`summary` and `content`, sent to the community's inbox. Lemmy takes no report of an account alone, so none goes there.
Lemmy 1.0 reads the reporter's site then (our Application at `/`, seen live), which it needs to pass the report on to
the moderators of a community on another server.
**Pasture evidence (2026-10-06, `lemmy.sh` and `lemmy19.sh`):** on 1.0 and 0.19 alice's report of lemmyuser's thread
and of a comment in it is kept as a `post_report` and a `comment_report` with her words, whose creator is "Reports
from privapub.test", and no report names her.
- **Flags:** exactly one `to` (community or site); `object` a URL or an array; the reason in `summary` or `content`.
- **Moderation trust:** an action is trusted when it is on the community's or the object's host (FEP-fe34), or when its
actor is in the moderators list Lemmy fetched.
@@ -524,7 +532,7 @@ on a Page: pins live in `featured`, locks in `Lock`.
| `ChatMessage` in and out (out to someone who writes to us that way, and to Lemmy 0.19 and Mbin by NodeInfo, done 2026-10-05) | P1 | `visibility: direct` |
| Outbound shape for Lemmy: both `to` and `cc`; the community in `to`; Public in the object, Create and Announce; votes and comments sent to the community inbox | P1 | — |
| Communities we host: pick `Announce(object)` per peer by NodeInfo (as PieFed does). Announcing to every follower instance, the author's included, is done and needed (pasture evidence below) | P1 | — |
| Flags from a `Service`-typed reporter actor with `to: [community]`; the reporter stays anonymous | P2 | — |
| Flags from a `Service`-typed reporter actor with `to: [community]`; the reporter stays anonymous: **done 2026-10-06** (`privapub_reports`, checked live on 1.0 and 0.19) | P2 | — |
| `Warn` → `moderation_warning` notification; `Resolve{Flag}` | P2 | AccountWarning |
| Remote communities: `description`, `language[]`, private (`locked`), `discoverable`; post tags | P2 | `Account.locked`, own `privapub.flairs[]` |
| Serve our communities' collections the way Lemmy reads them: inline outbox of `Announce{Create{Page}}`, inline featured Pages, inline moderators. Lemmy does not page. | P2 | — |
+6 -3
View File
@@ -275,6 +275,7 @@ and circles (see Owner decisions).
|---|---|
| May a persona join a remote event? | **Yes.** A persona joins and leaves another server's event (`Join`/`Leave`, sent to the event's organiser only). Its participation is public on that server, as on every platform with events. Events that take participants only by invitation or on another site are refused before anything is sent. |
| A first private message to Lemmy 0.19 or Mbin (G-0008) | **Decide by the server's software.** Lemmy before 1.0 and Mbin take a private message only as a `ChatMessage` and their actors do not say so, so a direct message to one account on a server whose NodeInfo names one of them goes as a `ChatMessage`: the one exception to "a server's software is for display only". |
| Reports to Lemmy (2026-10-06) | **In Lemmy's shape, for Lemmy only.** Lemmy takes a report only from a person or a service, about one post or comment, addressed to its community, so a report of a post in a community on a server whose NodeInfo names Lemmy leaves from an anonymous `Service`, `privapub_reports`, one `Flag` per post: the second exception to "a server's software is for display only". PieFed and Mbin, which speak Lemmy's shapes, join only once the pasture shows each keeps such a report with its reason; every other server keeps the instance actor's report. |
| An account a persona follows moves | **Move the follow, as Mastodon does.** After a verified `Move` the persona follows the new account (a Follow to its server) and unfollows the old, in the same lists; a mute or a block of the old account carries over. |
| Should PrivaPub pass on replies to a persona's posts? | **Yes, "the fediverse is broken without".** A public or unlisted reply that someone on another server writes to a persona's post goes on to the persona's followers, as Mastodon forwards it: the activity as its author signed it, never to the replier's own server, never for a post that is local-only, in a group or not shown to followers. Its edits and deletion follow the same way. |
@@ -540,8 +541,9 @@ The first refactor commit is a pure move with namespaces only. Logic changes fol
tags at `/tattoos`.
- **Blocks and mutes:** per avatar. **Block is not federated**: it sends Reject or Undo Follow instead and drops the
blocked actor's traffic. Domain blocks per account as well.
- **Reports:** inbound Flag becomes a `Report`. Outbound Flag is **sent by the instance actor**, so the reporting
persona isn't revealed. Moderator endpoints on `/clientapi`.
- **Reports:** inbound Flag becomes a `Report`. Outbound Flag is **sent by the instance actor**, or for a post in a
Lemmy community by the reporter `privapub_reports` (2026-10-06), so the reporting persona isn't revealed. Moderator
endpoints on `/clientapi`.
- **Locked accounts:** the follow-request flow.
### P4 Groups and privacy features
@@ -663,7 +665,8 @@ it, raw where it doesn't.
- 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);
- Flags from a `Service`-typed reporter actor.
- Flags from a `Service`-typed reporter actor: **done 2026-10-06** for Lemmy (owner decision below; `privapub_reports`,
one Flag per post `to` its community, checked live on 1.0 and 0.19).
- **GoToSocial interaction policies** (**done 2026-10-05**, `InteractionApprovals`): stored and shown as
`interaction_policy`; `ReplyRequest`/`LikeRequest`/`AnnounceRequest` where approval is needed; `Accept{result}`
verified and carried; replies a policy does not let in kept only with an authorization.
+12
View File
@@ -52,6 +52,18 @@ privapub_token() {
"$here/../smoke/oauth.sh" "$P" "$ROOT_USER" "$ROOT_PASS" "$persona" "read write follow" | cut -d' ' -f1
}
# p_report <authorization header> <account id> <reason> <status id...>: a persona reports an account's posts and asks for
# the report to be forwarded; prints whether PrivaPub says it was (Mastodon's "forwarded")
p_report() {
local auth=$1 account=$2 reason=$3; shift 3
local ids=() id
for id in "$@"; do ids+=(-d "status_ids[]=$id"); done
curl -s -X POST -H "$auth" "$P/api/v1/reports" -d "account_id=$account" "${ids[@]}" --data-urlencode "comment=$reason" \
-d 'category=other' -d 'forward=true' | j "print(d['forwarded'])"
}
# the anonymous Service that carries PrivaPub's reports to the servers that take them only from a person or a service
P_REPORTER=https://privapub.test/peasants/privapub_reports
# stats_check <host> <software>: the admin statistics name the peer's software and count traffic both ways.
stats_check() {
local host=$1 software=$2 admin found
+16
View File
@@ -112,6 +112,22 @@ until_true 45 '[ "$(p_banned)" = "True" ]' && ok "the community's ban of alice_l
[ "$(p_reply "while banned")" = "422" ] && ok "a banned persona's reply in the community is refused" || ko "a banned persona's reply was taken"
lm POST community/ban_user "{\"community_id\":$cats_on_lm,\"person_id\":$alice_on_lm,\"ban\":false,\"reason\":\"pasture\"}" >/dev/null
until_true 45 '[ "$(p_banned)" = "False" ]' && ok "the community's unban reaches PrivaPub" || ko "the community's unban did not reach PrivaPub"
echo " reports"
# Lemmy takes a report only from a person or a service, about one post or comment, addressed to its community (owner
# decision 2026-10-06): alice's report of lemmyuser's thread and comment leaves from PrivaPub's reporter, one Flag each,
# and never names her. Lemmy sends nothing back; what it keeps is read from its database.
lm POST comment "{\"post_id\":$lm_cats_id,\"content\":\"a Lemmy comment to report\"}" >/dev/null
lm_comment_on_p() { curl -s -H "$LAH" "$P/api/v1/statuses/$lm_cats_on_p/context" | j "print(next((s['id'] for s in d['descendants'] if 'a Lemmy comment to report' in s['content']), ''))"; }
until_true 45 '[ -n "$(lm_comment_on_p)" ]' && ok "lemmyuser's comment to report reaches PrivaPub" || ko "lemmyuser's comment never reached PrivaPub"
lm_author_on_p=$(curl -s -H "$LAH" "$P/api/v1/statuses/$lm_cats_on_p" | j "print(d['account']['id'])")
reason="a pasture report $(date +%s)"
[ "$(p_report "$LAH" "$lm_author_on_p" "$reason" "$lm_cats_on_p" "$(lm_comment_on_p)")" = "True" ] \
&& ok "PrivaPub says alice's report was forwarded" || ko "PrivaPub did not forward alice's report"
lm_reports() { podman exec pasture-postgres psql -U pasture -d lemmy -tAc "select count(*) from $1 r join person p on p.id = r.creator_id where r.reason = '$reason' and p.ap_id = '$P_REPORTER'"; }
until_true 45 '[ "$(lm_reports post_report)" = "1" ]' && ok "Lemmy keeps the thread's report from PrivaPub's reporter, with alice's words" || ko "Lemmy kept no report of the thread"
until_true 45 '[ "$(lm_reports comment_report)" = "1" ]' && ok "Lemmy keeps the comment's report from PrivaPub's reporter, with alice's words" || ko "Lemmy kept no report of the comment"
[ "$(podman exec pasture-postgres psql -U pasture -d lemmy -tAc "select count(*) from post_report r join person p on p.id = r.creator_id where p.ap_id like '%alice_lemmy%'")" = "0" ] \
&& ok "no report on Lemmy names alice" || ko "a report on Lemmy names alice"
# the community relays it as Announce{Delete}, believed once Lemmy answers 410 for the post; Lemmy sends what it queued
# every 30 seconds
lm POST post/remove "{\"post_id\":$lm_cats_id,\"removed\":true,\"reason\":\"pasture\"}" >/dev/null
+14
View File
@@ -117,6 +117,20 @@ until_true 45 '[ "$(p_banned)" = "True" ]' && ok "the community's ban of alice_l
[ "$(p_reply "while banned")" = "422" ] && ok "a banned persona's reply in the community is refused" || ko "a banned persona's reply was taken"
lm POST community/ban_user "{\"community_id\":$cats_on_lm,\"person_id\":$alice_on_lm,\"ban\":false,\"reason\":\"pasture\"}" >/dev/null
until_true 45 '[ "$(p_banned)" = "False" ]' && ok "the community's unban reaches PrivaPub" || ko "the community's unban did not reach PrivaPub"
echo " reports"
# as on Lemmy 1.0: the report leaves from PrivaPub's reporter, one Flag for the thread and one for the comment
lm POST comment "{\"post_id\":$lm_cats_id,\"content\":\"a Lemmy 0.19 comment to report\"}" >/dev/null
lm_comment_on_p() { curl -s -H "$LAH" "$P/api/v1/statuses/$lm_cats_on_p/context" | j "print(next((s['id'] for s in d['descendants'] if 'a Lemmy 0.19 comment to report' in s['content']), ''))"; }
until_true 45 '[ -n "$(lm_comment_on_p)" ]' && ok "lemmyuser's comment to report reaches PrivaPub" || ko "lemmyuser's comment never reached PrivaPub"
lm_author_on_p=$(curl -s -H "$LAH" "$P/api/v1/statuses/$lm_cats_on_p" | j "print(d['account']['id'])")
reason="a pasture report $(date +%s)"
[ "$(p_report "$LAH" "$lm_author_on_p" "$reason" "$lm_cats_on_p" "$(lm_comment_on_p)")" = "True" ] \
&& ok "PrivaPub says alice's report was forwarded" || ko "PrivaPub did not forward alice's report"
lm_reports() { podman exec pasture-postgres psql -U pasture -d lemmy19 -tAc "select count(*) from $1 r join person p on p.id = r.creator_id where r.reason = '$reason' and p.actor_id = '$P_REPORTER'"; }
until_true 45 '[ "$(lm_reports post_report)" = "1" ]' && ok "Lemmy 0.19 keeps the thread's report from PrivaPub's reporter, with alice's words" || ko "Lemmy 0.19 kept no report of the thread"
until_true 45 '[ "$(lm_reports comment_report)" = "1" ]' && ok "Lemmy 0.19 keeps the comment's report from PrivaPub's reporter, with alice's words" || ko "Lemmy 0.19 kept no report of the comment"
[ "$(podman exec pasture-postgres psql -U pasture -d lemmy19 -tAc "select count(*) from post_report r join person p on p.id = r.creator_id where p.actor_id like '%alice_lemmy19%'")" = "0" ] \
&& ok "no report on Lemmy 0.19 names alice" || ko "a report on Lemmy 0.19 names alice"
lm POST post/remove "{\"post_id\":$lm_cats_id,\"removed\":true,\"reason\":\"pasture\"}" >/dev/null
until_true 45 '[ -z "$(p_home_has "$lm_cats_thread")" ]' && ok "a moderator's removal reaches PrivaPub" || ko "a moderator's removal did not reach PrivaPub"